Compare commits

..
Author SHA1 Message Date
Alexander Graf 4022e7fae7 attempt to fix 2FA 2023-10-16 08:44:40 +02:00
sakisdog ac28a33b4d Fixed bug that was asking for 2FA code again and again. 2023-10-16 08:28:51 +02:00
33 changed files with 1483 additions and 1502 deletions
+5 -6
View File
@@ -8,23 +8,22 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: "Checkout Instaloader Repository" - name: "Checkout Instaloader Repository"
uses: actions/checkout@v4 uses: actions/checkout@v2
with: with:
fetch-depth: 0 # needed for building docs fetch-depth: 0 # needed for building docs
- name: "Setup Python" - name: "Setup Python"
uses: actions/setup-python@v5 uses: actions/setup-python@v2
with: with:
python-version: 3.12 python-version: 3.8
cache: 'pipenv'
- name: "Install Dependencies" - name: "Install Dependencies"
run: | run: |
python -m pip install pipenv==2024.2.0 python -m pip install pipenv==2023.2.4
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
- name: "Build Documentation" - name: "Build Documentation"
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run make -C docs html SPHINXOPTS="-W -n" run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run make -C docs html SPHINXOPTS="-W -n"
- name: "Deploy Documentation" - name: "Deploy Documentation"
if: github.event_name == 'push' && github.ref == 'refs/heads/master' if: github.event_name == 'push' && github.ref == 'refs/heads/master'
uses: JamesIves/github-pages-deploy-action@v4 uses: JamesIves/github-pages-deploy-action@v4.2.5
with: with:
branch: master branch: master
repository-name: instaloader/instaloader.github.io repository-name: instaloader/instaloader.github.io
+4 -5
View File
@@ -9,18 +9,17 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] python-version: ["3.8", "3.9", "3.10", "3.11"]
steps: steps:
- name: Checkout Instaloader Repository - name: Checkout Instaloader Repository
uses: actions/checkout@v4 uses: actions/checkout@v2
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v5 uses: actions/setup-python@v2
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache: 'pipenv'
- name: Install Dependencies - name: Install Dependencies
run: | run: |
python -m pip install pipenv==2024.2.0 python -m pip install pipenv==2023.2.4
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
- name: PyLint - name: PyLint
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run pylint instaloader run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run pylint instaloader
+4 -6
View File
@@ -11,19 +11,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: "Checkout Repository" - name: "Checkout Repository"
uses: actions/checkout@v4 uses: actions/checkout@v2
- name: "Setup Python" - name: "Setup Python"
uses: actions/setup-python@v5 uses: actions/setup-python@v2
with: with:
python-version: 3.12 python-version: 3.8
- name: "Get the tagged version" - name: "Get the tagged version"
id: get_version id: get_version
env: env:
GITHUB_REF: ${{ github.ref }} GITHUB_REF: ${{ github.ref }}
run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\/v/}
shell: bash shell: bash
- name: "Install Dependencies"
run: python -m pip install setuptools
- name: "Generate Python Package" - name: "Generate Python Package"
run: python setup.py sdist run: python setup.py sdist
- name: "Publish Distribution to PyPI" - name: "Publish Distribution to PyPI"
+5 -5
View File
@@ -2,7 +2,7 @@ name: Mark stale issues and pull requests
on: on:
schedule: schedule:
- cron: "55 7 * * *" - cron: "10 14 * * *"
jobs: jobs:
stale: stale:
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v9 - uses: actions/stale@v3
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
only-labels: 'question' only-labels: 'question'
@@ -21,14 +21,14 @@ jobs:
days-before-stale: 21 days-before-stale: 21
days-before-close: -1 days-before-close: -1
remove-stale-when-updated: false remove-stale-when-updated: false
- uses: actions/stale@v9 - uses: actions/stale@v1
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'There has been no activity on this issue for an extended period of time. This issue will be closed after further 14 days of inactivity.' stale-issue-message: 'There has been no activity on this issue for an extended period of time. This issue will be closed after further 14 days of inactivity.'
stale-pr-message: 'There has been no activity on this Pull Request for an extended period of time. This Pull Request will be closed after further 14 days of inactivity.' stale-pr-message: 'There has been no activity on this Pull Request for an extended period of time. This Pull Request will be closed after further 14 days of inactivity.'
stale-issue-label: 'stale' stale-issue-label: 'stale'
stale-pr-label: 'stale' stale-pr-label: 'stale'
exempt-issue-labels: 'leave open' exempt-issue-label: 'leave open'
exempt-pr-labels: 'leave open' exempt-pr-label: 'leave open'
days-before-stale: 189 days-before-stale: 189
days-before-close: 14 days-before-close: 14
+18 -9
View File
@@ -8,32 +8,41 @@ jobs:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout Instaloader repository - name: Checkout Instaloader repository
uses: actions/checkout@v4 uses: actions/checkout@v2
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v5 uses: actions/setup-python@v2
with: with:
python-version: "3.12" python-version: "3.9"
cache: 'pipenv'
architecture: x64 architecture: x64
- name: Get the tagged version - name: Get the tagged version
id: get_version id: get_version
env: env:
GITHUB_REF: ${{ github.ref }} GITHUB_REF: ${{ github.ref }}
run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\/v/}
shell: bash shell: bash
- name: Run custom python script for EXE creation - name: Run custom python script for EXE creation
env: env:
VERSION_TAG: v${{ steps.get_version.outputs.VERSION }} VERSION_TAG: v${{ steps.get_version.outputs.VERSION }}
run: python deploy/windows/create_exe.py run: python deploy/windows/create_exe.py
- name: Create draft release and upload EXE as release asset - name: Create draft release
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
id: create_release id: create_release
uses: softprops/action-gh-release@v2 uses: actions/create-release@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tag_name: v${{ steps.get_version.outputs.VERSION }} tag_name: v${{ steps.get_version.outputs.VERSION }}
name: Version ${{ steps.get_version.outputs.VERSION }} release_name: Version ${{ steps.get_version.outputs.VERSION }}
draft: true draft: true
prerelease: contains(steps.get_version.outputs.VERSION, 'a') || contains(steps.get_version.outputs.VERSION, 'b') || contains(steps.get_version.outputs.VERSION, 'c') prerelease: contains(steps.get_version.outputs.VERSION, 'a') || contains(steps.get_version.outputs.VERSION, 'b') || contains(steps.get_version.outputs.VERSION, 'c')
files: ./instaloader-v${{ steps.get_version.outputs.VERSION }}-windows-standalone.zip - name: Upload EXE as release asset
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
id: upload-release-asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./instaloader-v${{ steps.get_version.outputs.VERSION }}-windows-standalone.zip
asset_name: instaloader-v${{ steps.get_version.outputs.VERSION }}-windows-standalone.zip
asset_content_type: application/zip
+1 -1
View File
@@ -351,7 +351,7 @@ single-line-if-stmt=no
allow-any-import-level= allow-any-import-level=
# Allow explicit reexports by alias from a package __init__. # Allow explicit reexports by alias from a package __init__.
allow-reexport-from-package=yes allow-reexport-from-package=no
# Allow wildcard imports from modules that define __all__. # Allow wildcard imports from modules that define __all__.
allow-wildcard-with-all=no allow-wildcard-with-all=no
-1
View File
@@ -6,5 +6,4 @@ Instaloader is written by
- Alexander Graf (@aandergr) - Alexander Graf (@aandergr)
- André Koch-Kramer (@Thammus) - André Koch-Kramer (@Thammus)
- Lars Lindqvist (@e5150) - Lars Lindqvist (@e5150)
- Eduardo M Kalinowksi (@ekalin)
- ... and many more, see https://github.com/instaloader/instaloader/graphs/contributors - ... and many more, see https://github.com/instaloader/instaloader/graphs/contributors
+9 -8
View File
@@ -6,17 +6,18 @@ verify_ssl = true
[dev-packages] [dev-packages]
pylint = "*" pylint = "*"
mypy = "*" mypy = "*"
sphinx = "*" sphinx = "~=2.3"
pyinstaller = "*" pyinstaller = "*"
pefile = "*"
pywin32-ctypes = "*"
psutil = "*" psutil = "*"
typing-extensions = "*"
types-requests = "*" types-requests = "*"
sphinx-autodoc-typehints = "*" typed-ast = "*"
sphinxcontrib-jquery = "*" jinja2 = "~=3.0.1"
tomli = "*" # Needed for Pylint for "python_version < '3.11'" wrapt = "*" # Needed for Python<3.11
dill = "*" # Needed for Pylint for "python_version < '3.11'" tomli = "*" # Needed for Python<3.11
pywin32-ctypes = "*" # Needed for pyinstaller on Windows dill = "*" # Needed for Python<3.11
pefile = "*" # Needed for pyinstaller on Windows
[packages] [packages]
requests = "*" requests = "*"
browser-cookie3 = "*"
Generated
+555 -545
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -53,7 +53,7 @@
:: ::
instaloader [--comments] [--geotags] instaloader [--comments] [--geotags]
[--stories] [--highlights] [--tagged] [--reels] [--igtv] [--stories] [--highlights] [--tagged] [--igtv]
[--login YOUR-USERNAME] [--fast-update] [--login YOUR-USERNAME] [--fast-update]
profile | "#hashtag" | :stories | :feed | :saved profile | "#hashtag" | :stories | :feed | :saved
@@ -125,6 +125,7 @@ Supporters
| Instaloader is proudly sponsored by | Instaloader is proudly sponsored by
| `@rocketapi-io <https://github.com/rocketapi-io>`__ | `@rocketapi-io <https://github.com/rocketapi-io>`__
| `@socialmethod <https://github.com/socialmethod>`__
See `Alex' GitHub Sponsors <https://github.com/sponsors/aandergr>`__ page for See `Alex' GitHub Sponsors <https://github.com/sponsors/aandergr>`__ page for
how you can sponsor the development of Instaloader! how you can sponsor the development of Instaloader!
+1 -1
View File
@@ -44,7 +44,7 @@ with open('__main__.py', 'w+') as f:
f.writelines(lines) f.writelines(lines)
# install dependencies and invoke PyInstaller # install dependencies and invoke PyInstaller
commands = ["pip install pipenv==2024.2.0", commands = ["pip install pipenv==2023.2.4",
f"pipenv --python {sys.version_info.major}.{sys.version_info.minor} sync --dev", f"pipenv --python {sys.version_info.major}.{sys.version_info.minor} sync --dev",
"pipenv run pyinstaller --log-level=DEBUG instaloader.spec"] "pipenv run pyinstaller --log-level=DEBUG instaloader.spec"]
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -21,7 +21,7 @@
{% block header %} {% block header %}
<nav class="navbar navbar-light navbar-expand bg-light border-bottom"> <nav class="navbar navbar-light navbar-expand bg-light border-bottom">
<a class="navbar-brand" href="{{ pathto(master_doc) }}"> <a class="navbar-brand" href="{{ pathto(master_doc) }}">
<img src="{{ pathto('_static/logo.png', 1) }}" width="30" height="30" class="d-inline-block align-top" alt="logo"> <img src="{{ pathto('_static/' + logo, 1) }}" width="30" height="30" class="d-inline-block align-top" alt="logo">
<span class="ml-3">Instaloader</span> <span class="ml-3">Instaloader</span>
</a> </a>
<ul class="navbar-nav mr-auto"> <ul class="navbar-nav mr-auto">
+11 -49
View File
@@ -75,7 +75,7 @@ What to Download
Instaloader supports the following targets: Instaloader supports the following targets:
- ``profile`` - ``profile``
Public profile, or private profile with :ref:`login<login>`. Public profile, or private profile with :option:`--login`.
If an already-downloaded profile has been renamed, Instaloader automatically If an already-downloaded profile has been renamed, Instaloader automatically
finds it by its unique ID and renames the folder accordingly. finds it by its unique ID and renames the folder accordingly.
@@ -92,9 +92,6 @@ Instaloader supports the following targets:
- :option:`--tagged` - :option:`--tagged`
to **download posts where the user is tagged**, and to **download posts where the user is tagged**, and
- :option:`--reels`
to **download Reels videos**.
- :option:`--igtv` - :option:`--igtv`
to **download IGTV videos**. to **download IGTV videos**.
@@ -105,28 +102,27 @@ Instaloader supports the following targets:
Posts tagged with a given location; the location ID is the numerical ID Posts tagged with a given location; the location ID is the numerical ID
Instagram labels a location with (e.g. Instagram labels a location with (e.g.
\https://www.instagram.com/explore/locations/**362629379**/plymouth-naval-memorial/). \https://www.instagram.com/explore/locations/**362629379**/plymouth-naval-memorial/).
Requires :ref:`login<login>`. Requires :option:`--login`.
.. versionadded:: 4.2 .. versionadded:: 4.2
- ``:stories`` - ``:stories``
The currently-visible **stories** of your followees (requires The currently-visible **stories** of your followees (requires
:ref:`login<login>`). :option:`--login`).
- ``:feed`` - ``:feed``
Your **feed** (requires :ref:`login<login>`). Your **feed** (requires :option:`--login`).
- ``:saved`` - ``:saved``
Posts which are marked as **saved** (requires :ref:`login<login>`). 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 :ref:`login<login>`). ``profile`` (requires :option:`--login`).
- ``-post`` - ``-post``
Replace **post** with the post's shortcode to download single post. Must be preceded by ``--`` in Replace **post** with the post's shortcode to download single post. Must be preceded by ``--`` in
the argument list to not be mistaken as an option flag. For example, to download the post the argument list to not be mistaken as an option flag::
https://www.instagram.com/p/**B_K4CykAOtf**, run the command::
instaloader -- -B_K4CykAOtf instaloader -- -B_K4CykAOtf
@@ -144,7 +140,7 @@ downloads the pictures and videos and their captions. You can specify
- :option:`--geotags` - :option:`--geotags`
**download geotags** of each post and save them as **download geotags** of each post and save them as
Google Maps link (requires :ref:`login<login>`), Google Maps link (requires :option:`--login`),
For a reference of all supported command line options, see For a reference of all supported command line options, see
:ref:`command-line-options`. :ref:`command-line-options`.
@@ -235,7 +231,7 @@ The filter string must be a
where the attributes from :class:`Post` or where the attributes from :class:`Post` or
:class:`StoryItem` respectively are defined. :class:`StoryItem` respectively are defined.
The following attributes can be used with both Id est, the following attributes can be used with both
:option:`--post-filter` and :option:`--storyitem-filter`: :option:`--post-filter` and :option:`--storyitem-filter`:
- :attr:`~Post.owner_username` (str), :attr:`~Post.owner_id` (int) - :attr:`~Post.owner_username` (str), :attr:`~Post.owner_id` (int)
@@ -259,7 +255,7 @@ The following attributes can be used with both
As :option:`--post-filter`, the following attributes can be used additionally: As :option:`--post-filter`, the following attributes can be used additionally:
- :attr:`~Post.viewer_has_liked` (bool) - :attr:`~Post.viewer_has_liked` (bool)
Whether user (with :ref:`login<login>`) has liked given post. To download the Whether user (with :option:`--login`) has liked given post. To download the
pictures from your feed that you have liked:: pictures from your feed that you have liked::
instaloader --login=your_username --post-filter=viewer_has_liked :feed instaloader --login=your_username --post-filter=viewer_has_liked :feed
@@ -311,47 +307,13 @@ the post's caption::
instaloader --post-metadata-txt="{likes} likes." <target> instaloader --post-metadata-txt="{likes} likes." <target>
Note that with this feature, it is possible to easily and quickly extract Note that with this feature, it is possible to easily and fastly extract
additional metadata of already-downloaded posts, by reimporting their JSON additional metadata of already-downloaded posts, by reimporting their JSON
files. Say, you now also want to export the number of comments the Posts had files. Say, you now also want to export the number of comments the Posts had
when they were downloaded:: when they were downloaded::
instaloader --post-metadata-txt="{likes} likes, {comments} comments." <target>/*.json.xz instaloader --post-metadata-txt="{likes} likes, {comments} comments." <target>/*.json.xz
.. _exit_codes:
Exit codes
^^^^^^^^^^
Different exit codes are used to indicate different kinds of error:
0
No error, all downloads were successful.
1
A non-fatal error happened. One or more posts, or even one or more
profiles could not be downloaded, but execution was not stopped. The
errors are repeated at the end of the log for easy access.
2
Command-line error. An unrecognized option was passed, or an invalid
combination of options, for example. No interaction with Instagram
was made.
3
Login error. It was not possible to login. Downloads were not
attempted.
4
Fatal download error. Downloads were interrupted and no further
attempts were made. Happens when a response with one of the status
codes in the :option:`--abort-on` option were passed, or when
Instagram logs the user out during downloads.
5
Interrupted by the user. Happens when the user presses Control-C or
sends SIGINT to the process.
.. _instaloader-as-cronjob: .. _instaloader-as-cronjob:
Instaloader as Cronjob Instaloader as Cronjob
+6 -35
View File
@@ -26,7 +26,7 @@ Targets
^^^^^^^ ^^^^^^^
Specify a list of targets. For each of these, Instaloader creates a folder and Specify a list of targets. For each of these, Instaloader creates a folder and
stores all posts along with the pictures' captions there. stores all posts along with the pictures's captions there.
.. include:: basic-usage.rst .. include:: basic-usage.rst
:start-after: targets-start :start-after: targets-start
@@ -61,13 +61,13 @@ What to Download of each Post
Download geotags when available. Geotags are stored as a text file with Download geotags when available. Geotags are stored as a text file with
the location's name and a Google Maps link. This requires an additional the location's name and a Google Maps link. This requires an additional
request to the Instagram server for each picture. Requires :ref:`login<login>`. request to the Instagram server for each picture. Requires :option:`--login`.
.. option:: --comments, -C .. option:: --comments, -C
Download and update comments for each post. This requires an additional Download and update comments for each post. This requires an additional
request to the Instagram server for each post, which is why it is disabled by request to the Instagram server for each post, which is why it is disabled by
default. Requires :ref:`login<login>`. default.
.. option:: --no-captions .. option:: --no-captions
@@ -116,12 +116,12 @@ What to Download of each Profile
.. option:: --stories, -s .. option:: --stories, -s
Also download stories of each profile that is downloaded. Requires Also download stories of each profile that is downloaded. Requires
:ref:`login<login>`. :option:`--login`.
.. option:: --highlights .. option:: --highlights
Also download highlights of each profile that is downloaded. Requires Also download highlights of each profile that is downloaded. Requires
:ref:`login<login>`. :option:`--login`.
.. versionadded:: 4.1 .. versionadded:: 4.1
@@ -131,12 +131,6 @@ What to Download of each Profile
.. versionadded:: 4.1 .. versionadded:: 4.1
.. option:: --reels
Also download Reels videos.
.. versionadded:: 4.14
.. option:: --igtv .. option:: --igtv
Also download IGTV videos. Also download IGTV videos.
@@ -189,7 +183,6 @@ Which Posts to Download
Do not attempt to download more than COUNT posts. Applies to Do not attempt to download more than COUNT posts. Applies to
``#hashtag``, ``%location_id``, ``:feed``, and ``:saved``. ``#hashtag``, ``%location_id``, ``:feed``, and ``:saved``.
.. _login:
Login (Download Private Profiles) Login (Download Private Profiles)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -199,32 +192,10 @@ profiles. To login, pass the :option:`--login` option. Your session cookie (not
password!) will be saved to a local file to be reused next time you want password!) will be saved to a local file to be reused next time you want
Instaloader to login. Instaloader to login.
Instead of :option:`--login`, it is possible to use
:option:`--load-cookies` to import a session from a browser.
.. option:: --login YOUR-USERNAME, -l YOUR-USERNAME .. option:: --login YOUR-USERNAME, -l YOUR-USERNAME
Login name (profile name) for your Instagram account. Login name (profile name) for your Instagram account.
.. option:: --load-cookies BROWSER-NAME, -b BROWSER-NAME
Use Instagram cookie in your browser to login.
This feature requires the browser_cookie3 library.
Compatible with :option:`--cookiefile` if you want to load cookies from browser profiles.
Incompatible with :option:`--login` due to potential username mismatch between user input and browser login.
Supported browsers: Brave, Chrome, Chromium, Edge, Firefox, LibreWolf, Opera, Opera_GX, Safari and Vivaldi.
In subsequent runs, you can just use :option:`--login` to reuse the
same session, which is saved by Instaloader.
.. versionadded:: 4.11
.. option:: --cookiefile COOKIE-FILE, -B COOKIE-FILE
Cookie file path of a browser profile to load cookies from.
.. versionadded:: 4.11
.. option:: --sessionfile SESSIONFILE, -f SESSIONFILE .. option:: --sessionfile SESSIONFILE, -f SESSIONFILE
Path for loading and storing session key file. Defaults to Path for loading and storing session key file. Defaults to
@@ -310,7 +281,7 @@ How to Download
.. option:: --user-agent USER_AGENT .. option:: --user-agent USER_AGENT
User Agent to use for HTTP requests. Per default, Instaloader pretends being User Agent to use for HTTP requests. Per default, Instaloader pretends being
Chrome/127 on Linux. Chrome/92 on Linux.
.. option:: --max-connection-attempts N .. option:: --max-connection-attempts N
+354 -63
View File
@@ -1,84 +1,56 @@
# Configuration file for the Sphinx documentation builder. #!/usr/bin/env python3
# -*- coding: utf-8 -*-
# #
# For the full list of built-in configuration values, see the documentation: # Instaloader documentation build configuration file, created by
# https://www.sphinx-doc.org/en/master/usage/configuration.html # sphinx-quickstart on Fri Aug 25 15:38:26 2017.
#
# This file is execfile()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os import os
import platform import platform
import subprocess import subprocess
import sys import sys
import typing
sys.path.insert(0, os.path.abspath('..')) sys.path.insert(0, os.path.abspath('..'))
import docs.sphinx_autodoc_typehints as sphinx_autodoc_typehints
# -- Project information ----------------------------------------------------- # -- General configuration ------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'Instaloader' # If your documentation needs a minimal Sphinx version, state it here.
copyright = '2023, Alexander Graf and Andre Koch-Kramer' #
author = 'Alexander Graf and Andre Koch-Kramer' # needs_sphinx = '1.0'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [ extensions = [
"sphinx.ext.autodoc", 'sphinx.ext.autodoc',
"sphinx.ext.extlinks", 'sphinx.ext.githubpages',
"sphinx.ext.githubpages", 'sphinx.ext.intersphinx',
"sphinx.ext.intersphinx", 'sphinx.ext.extlinks'
"sphinxcontrib.jquery",
] ]
# autodoc options autodoc_default_flags = ['show-inheritance', 'members', 'undoc-members']
autodoc_default_options = { autodoc_member_order = 'bysource'
'show-inheritance': True,
'members': True,
'undoc-members': True,
}
# extlinks options intersphinx_mapping = {'python': ('https://docs.python.org/3', None),
'requests': ('https://requests.readthedocs.io/en/latest/', None)}
extlinks = { nitpick_ignore = [('py:class', 'typing.Tuple')]
'issue': (
'https://github.com/instaloader/instaloader/issues/%s',
'Issue #%s'
),
'example': (
'https://raw.githubusercontent.com/instaloader/instaloader/master/docs/codesnippets/%s',
'Example %s'
),
}
# intersphinx options
intersphinx_mapping = {
'python': ('https://docs.python.org/3', None),
'requests': ('https://requests.readthedocs.io/en/latest/', None),
}
# other options
templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# ignore TypeVar as it is currently unsupported by Sphinx
nitpick_ignore = [('py:class', 'instaloader.nodeiterator.T')]
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
pygments_style = 'fruity'
html_theme = 'basic'
html_static_path = ['_static']
html_logo = "logo.png"
html_css_files = ['instaloaderdoc.css']
html_js_files = ['instaloaderdoc.js']
# get release number
current_release = subprocess.check_output(["git", "describe", "--abbrev=0"]).decode("ascii")[1:-1] current_release = subprocess.check_output(["git", "describe", "--abbrev=0"]).decode("ascii")[1:-1]
date_format = "%e %b %Y" if platform.system() != "Windows" else "%d %b %Y" date_format = "%e %b %Y" if platform.system() != "Windows" else "%d %b %Y"
@@ -86,3 +58,322 @@ current_release_date = subprocess.check_output(
["git", "log", "-1", "--tags", "--format=%ad", "--date=format:"+date_format]).decode("ascii")[:-1] ["git", "log", "-1", "--tags", "--format=%ad", "--date=format:"+date_format]).decode("ascii")[:-1]
html_context = {'current_release': current_release, 'current_release_date': current_release_date} html_context = {'current_release': current_release, 'current_release_date': current_release_date}
extlinks = {
'issue': (
'https://github.com/instaloader/instaloader/issues/%s',
'Issue #'
),
'example': (
'https://raw.githubusercontent.com/instaloader/instaloader/master/docs/codesnippets/%s',
'Example '
),
}
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
# The encoding of source files.
#
# source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = 'Instaloader'
copyright = '2017-2020, Alexander Graf and Andre Koch-Kramer'
author = 'Alexander Graf and Andre Koch-Kramer'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
#version = '3.0'
# The full version, including alpha/beta/rc tags.
#release = '3.0rc0'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = "en"
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#
# today = ''
#
# Else, today_fmt is used as the format for a strftime call.
#
# today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# The reST default role (used for this markup: `text`) to use for all
# documents.
#
# default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#
# add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#
add_module_names = False
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#
# show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'fruity'
# A list of ignored prefixes for module index sorting.
# modindex_common_prefix = []
# If true, keep warnings as "system message" paragraphs in the built documents.
# keep_warnings = False
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = False
# -- Options for HTML output ----------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'basic'
# Add any paths that contain custom themes here, relative to this directory.
# html_theme_path = []
# The name for this set of Sphinx documents.
# "<project> v<release> documentation" by default.
#
# html_title = 'Instaloader v3.0rc0'
# A shorter title for the navigation bar. Default is the same as html_title.
#
# html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#
html_logo = "logo.png"
# The name of an image file (relative to this directory) to use as a favicon of
# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#
html_favicon = "favicon.ico"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied
# directly to the root of the documentation.
#
# html_extra_path = []
# If not None, a 'Last updated on:' timestamp is inserted at every page
# bottom, using the given strftime format.
# The empty string is equivalent to '%b %d, %Y'.
#
# html_last_updated_fmt = None
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#
# html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#
#html_sidebars = {'**': ["relations.html", "links.html"] }
html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#
# html_additional_pages = {}
# If false, no module index is generated.
#
html_domain_indices = False
# If false, no index is generated.
#
html_use_index = True
# If true, the index is split into individual pages for each letter.
#
# html_split_index = False
# If true, links to the reST sources are added to the pages.
#
html_show_sourcelink = False
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#
html_show_sphinx = False
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#
html_show_copyright = False
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#
# html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
# html_file_suffix = None
# Language to be used for generating the HTML full-text search index.
# Sphinx supports the following languages:
# 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja'
# 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh'
#
# html_search_language = 'en'
# A dictionary with options for the search language support, empty by default.
# 'ja' uses this config value.
# 'zh' user can custom change `jieba` dictionary path.
#
# html_search_options = {'type': 'default'}
# The name of a javascript file (relative to the configuration directory) that
# implements a search results scorer. If empty, the default will be used.
#
# html_search_scorer = 'scorer.js'
# Output file base name for HTML help builder.
htmlhelp_basename = 'Instaloaderdoc'
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, 'Instaloader.tex', 'Instaloader Documentation',
'Alexander Graf, André Koch-Kramer', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#
# latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#
# latex_use_parts = False
# If true, show page references after internal links.
#
# latex_show_pagerefs = False
# If true, show URL addresses after external links.
#
# latex_show_urls = False
# Documents to append as an appendix to all manuals.
#
# latex_appendices = []
# It false, will not define \strong, \code, itleref, \crossref ... but only
# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added
# packages.
#
# latex_keep_old_macro_names = True
# If false, no module index is generated.
#
# latex_domain_indices = True
# -- Options for manual page output ---------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, 'instaloader', 'Instaloader Documentation',
[author], 1)
]
# If true, show URL addresses after external links.
#
# man_show_urls = False
# -- Options for Texinfo output -------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, 'Instaloader', 'Instaloader Documentation',
author, 'Instaloader', 'One line description of project.',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
#
# texinfo_appendices = []
# If false, no module index is generated.
#
# texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#
# texinfo_show_urls = 'footnote'
# If true, do not generate a @detailmenu in the "Top" node's menu.
#
# texinfo_no_detailmenu = False
def setup(app):
typing.TYPE_CHECKING = True
app.add_stylesheet("instaloaderdoc.css")
app.connect('autodoc-process-signature', sphinx_autodoc_typehints.process_signature)
app.connect('autodoc-process-docstring', sphinx_autodoc_typehints.process_docstring)
+1 -1
View File
@@ -48,7 +48,7 @@ See :ref:`install` for more options on how to install Instaloader.
:: ::
instaloader [--comments] [--geotags] instaloader [--comments] [--geotags]
[--stories] [--highlights] [--tagged] [--reels] [--igtv] [--stories] [--highlights] [--tagged] [--igtv]
[--login YOUR-USERNAME] [--fast-update] [--login YOUR-USERNAME] [--fast-update]
profile | "#hashtag" | %location_id | profile | "#hashtag" | %location_id |
:stories | :feed | :saved :stories | :feed | :saved
+1 -11
View File
@@ -2,7 +2,7 @@ Exceptions
^^^^^^^^^^ ^^^^^^^^^^
.. module:: instaloader .. module:: instaloader
:no-index: :noindex:
.. highlight:: python .. highlight:: python
@@ -27,26 +27,16 @@ Exceptions
.. autoexception:: LoginRequiredException .. autoexception:: LoginRequiredException
.. autoexception:: LoginException
.. versionadded:: 4.12
.. autoexception:: TwoFactorAuthRequiredException .. autoexception:: TwoFactorAuthRequiredException
.. versionadded:: 4.2 .. versionadded:: 4.2
.. versionchanged:: 4.12
Inherits LoginException
.. autoexception:: InvalidArgumentException .. autoexception:: InvalidArgumentException
.. autoexception:: BadResponseException .. autoexception:: BadResponseException
.. autoexception:: BadCredentialsException .. autoexception:: BadCredentialsException
.. versionchanged:: 4.12
Inherits LoginException
.. autoexception:: PostChangedException .. autoexception:: PostChangedException
.. autoexception:: QueryReturnedNotFoundException .. autoexception:: QueryReturnedNotFoundException
+1 -1
View File
@@ -2,7 +2,7 @@
^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. module:: instaloader .. module:: instaloader
:no-index: :noindex:
.. highlight:: python .. highlight:: python
+1 -1
View File
@@ -2,7 +2,7 @@
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. module:: instaloader .. module:: instaloader
:no-index: :noindex:
.. highlight:: python .. highlight:: python
+1 -1
View File
@@ -2,7 +2,7 @@ Resumable Iterations
^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^
.. module:: instaloader .. module:: instaloader
:no-index: :noindex:
.. highlight:: python .. highlight:: python
+1 -9
View File
@@ -2,7 +2,7 @@ Instagram Structures
^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^
.. module:: instaloader .. module:: instaloader
:no-index: :noindex:
.. highlight:: python .. highlight:: python
@@ -71,14 +71,6 @@ TopSearchResults
.. versionadded:: 4.3 .. versionadded:: 4.3
TitlePic
""""""""
.. autoclass:: TitlePic
:no-show-inheritance:
.. versionadded:: 4.8
Loading and Saving Loading and Saving
"""""""""""""""""" """"""""""""""""""
+251
View File
@@ -0,0 +1,251 @@
# sphinx_autodoc_typehints from Nov 20 2018 (version 1.5.2).
# https://github.com/agronholm/sphinx-autodoc-typehints
# (slightly modified to fix class links, maybe related to agronholm/sphinx-autodoc-typehints#38)
#
# This is the MIT license: http://www.opensource.org/licenses/mit-license.php
#
# Copyright (c) Alex Grönholm
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of this
# software and associated documentation files (the "Software"), to deal in the Software
# without restriction, including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
# to whom the Software is furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all copies or
# substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
# INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
# FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.
#
import inspect
from typing import get_type_hints, TypeVar, Any, AnyStr, Generic, Union
from sphinx.util import logging
from sphinx.util.inspect import Signature
try:
from inspect import unwrap
except ImportError:
def unwrap(func, *, stop=None):
"""This is the inspect.unwrap() method copied from Python 3.5's standard library."""
if stop is None:
def _is_wrapper(f):
return hasattr(f, '__wrapped__')
else:
def _is_wrapper(f):
return hasattr(f, '__wrapped__') and not stop(f)
f = func # remember the original func for error reporting
memo = {id(f)} # Memoise by id to tolerate non-hashable objects
while _is_wrapper(func):
func = func.__wrapped__
id_func = id(func)
if id_func in memo:
raise ValueError('wrapper loop when unwrapping {!r}'.format(f))
memo.add(id_func)
return func
logger = logging.getLogger(__name__)
def format_annotation(annotation):
if inspect.isclass(annotation) and annotation.__module__ == 'builtins':
if annotation.__qualname__ == 'NoneType':
return '``None``'
else:
return ':py:class:`{}`'.format(annotation.__qualname__)
annotation_cls = annotation if inspect.isclass(annotation) else type(annotation)
class_name = None
if annotation_cls.__module__ == 'typing':
params = None
prefix = ':py:class:'
module = 'typing'
extra = ''
if inspect.isclass(getattr(annotation, '__origin__', None)):
annotation_cls = annotation.__origin__
try:
if Generic in annotation_cls.mro():
module = annotation_cls.__module__
except TypeError:
pass # annotation_cls was either the "type" object or typing.Type
if annotation is Any:
return ':py:data:`~typing.Any`'
elif annotation is AnyStr:
return ':py:data:`~typing.AnyStr`'
elif isinstance(annotation, TypeVar):
return '\\%r' % annotation
elif (annotation is Union or getattr(annotation, '__origin__', None) is Union or
hasattr(annotation, '__union_params__')):
prefix = ':py:data:'
class_name = 'Union'
if hasattr(annotation, '__union_params__'):
params = annotation.__union_params__
elif hasattr(annotation, '__args__'):
params = annotation.__args__
if params and len(params) == 2 and (hasattr(params[1], '__qualname__') and
params[1].__qualname__ == 'NoneType'):
class_name = 'Optional'
params = (params[0],)
elif annotation_cls.__qualname__ == 'Tuple' and hasattr(annotation, '__tuple_params__'):
params = annotation.__tuple_params__
if annotation.__tuple_use_ellipsis__:
params += (Ellipsis,)
elif annotation_cls.__qualname__ == 'Callable':
prefix = ':py:data:'
arg_annotations = result_annotation = None
if hasattr(annotation, '__result__'):
arg_annotations = annotation.__args__
result_annotation = annotation.__result__
elif getattr(annotation, '__args__', None):
arg_annotations = annotation.__args__[:-1]
result_annotation = annotation.__args__[-1]
if arg_annotations in (Ellipsis, (Ellipsis,)):
params = [Ellipsis, result_annotation]
elif arg_annotations is not None:
params = [
'\\[{}]'.format(
', '.join(format_annotation(param) for param in arg_annotations)),
result_annotation
]
elif hasattr(annotation, 'type_var'):
# Type alias
class_name = annotation.name
params = (annotation.type_var,)
elif getattr(annotation, '__args__', None) is not None:
params = annotation.__args__
elif hasattr(annotation, '__parameters__'):
params = annotation.__parameters__
if params:
extra = '\\[{}]'.format(', '.join(format_annotation(param) for param in params))
if not class_name:
class_name = annotation_cls.__qualname__.title()
return '{}`~{}.{}`{}'.format(prefix, module, class_name, extra)
elif annotation is Ellipsis:
return '...'
elif inspect.isclass(annotation) or inspect.isclass(getattr(annotation, '__origin__', None)):
if not inspect.isclass(annotation):
annotation_cls = annotation.__origin__
extra = ''
if Generic in annotation_cls.mro():
params = (getattr(annotation, '__parameters__', None) or
getattr(annotation, '__args__', None))
extra = '\\[{}]'.format(', '.join(format_annotation(param) for param in params))
module = annotation.__module__.split('.')[0] # hack to 'fix' class linking for Instaloader project
return ':py:class:`~{}.{}`{}'.format(module, annotation_cls.__qualname__,
extra)
return str(annotation)
def process_signature(app, what: str, name: str, obj, options, signature, return_annotation):
if not callable(obj):
return
if what in ('class', 'exception'):
obj = getattr(obj, '__init__', getattr(obj, '__new__', None))
if not getattr(obj, '__annotations__', None):
return
obj = unwrap(obj)
signature = Signature(obj)
parameters = [
param.replace(annotation=inspect.Parameter.empty)
for param in signature.signature.parameters.values()
]
if parameters:
if what in ('class', 'exception'):
del parameters[0]
elif what == 'method':
outer = inspect.getmodule(obj)
for clsname in obj.__qualname__.split('.')[:-1]:
outer = getattr(outer, clsname)
method_name = obj.__name__
if method_name.startswith("__") and not method_name.endswith("__"):
# If the method starts with double underscore (dunder)
# Python applies mangling so we need to prepend the class name.
# This doesn't happen if it always ends with double underscore.
class_name = obj.__qualname__.split('.')[-2]
method_name = "_{c}{m}".format(c=class_name, m=method_name)
method_object = outer.__dict__[method_name]
if not isinstance(method_object, (classmethod, staticmethod)):
del parameters[0]
signature.signature = signature.signature.replace(
parameters=parameters,
return_annotation=inspect.Signature.empty)
return signature.format_args().replace('\\', '\\\\'), None
def process_docstring(app, what, name, obj, options, lines):
if isinstance(obj, property):
obj = obj.fget
if callable(obj):
if what in ('class', 'exception'):
obj = getattr(obj, '__init__')
obj = unwrap(obj)
try:
type_hints = get_type_hints(obj)
except (AttributeError, TypeError):
# Introspecting a slot wrapper will raise TypeError
return
except NameError as exc:
logger.warning('Cannot resolve forward reference in type annotations of "%s": %s',
name, exc)
type_hints = obj.__annotations__
for argname, annotation in type_hints.items():
if argname.endswith('_'):
argname = '{}\\_'.format(argname[:-1])
formatted_annotation = format_annotation(annotation)
if argname == 'return':
if what in ('class', 'exception'):
# Don't add return type None from __init__()
continue
insert_index = len(lines)
for i, line in enumerate(lines):
if line.startswith(':rtype:'):
insert_index = None
break
elif line.startswith(':return:') or line.startswith(':returns:'):
insert_index = i
if insert_index is not None:
if insert_index == len(lines):
# Ensure that :rtype: doesn't get joined with a paragraph of text, which
# prevents it being interpreted.
lines.append('')
insert_index += 1
lines.insert(insert_index, ':rtype: {}'.format(formatted_annotation))
else:
searchfor = ':param {}:'.format(argname)
for i, line in enumerate(lines):
if line.startswith(searchfor):
lines.insert(i, ':type {}: {}'.format(argname, formatted_annotation))
break
+1 -1
View File
@@ -33,7 +33,7 @@ happen under normal conditions, consider adjusting the
There have been observations that services, that in their nature offer There have been observations that services, that in their nature offer
promiscuous IP addresses, such as cloud, VPN and public proxy services, might be promiscuous IP addresses, such as cloud, VPN and public proxy services, might be
subject to significantly stricter limits for anonymous access. However, subject to significantly stricter limits for anonymous access. However,
:ref:`logged-in accesses<login>` do not seem to be affected. logged-in accesses (see :option:`--login`) do not seem to be affected.
Instaloader allows to adjust the rate controlling behavior by overriding Instaloader allows to adjust the rate controlling behavior by overriding
:class:`instaloader.RateController`. :class:`instaloader.RateController`.
+8 -24
View File
@@ -1,7 +1,7 @@
"""Download pictures (or videos) along with their captions and other metadata from Instagram.""" """Download pictures (or videos) along with their captions and other metadata from Instagram."""
__version__ = '4.14' __version__ = '4.10'
try: try:
@@ -13,26 +13,10 @@ else:
win_unicode_console.enable() win_unicode_console.enable()
from .exceptions import * from .exceptions import *
from .instaloader import Instaloader as Instaloader from .instaloader import Instaloader
from .instaloadercontext import (InstaloaderContext as InstaloaderContext, from .instaloadercontext import InstaloaderContext, RateController
RateController as RateController) from .lateststamps import LatestStamps
from .lateststamps import LatestStamps as LatestStamps from .nodeiterator import NodeIterator, FrozenNodeIterator, resumable_iteration
from .nodeiterator import (NodeIterator as NodeIterator, from .structures import (Hashtag, Highlight, Post, PostSidecarNode, PostComment, PostCommentAnswer, PostLocation,
FrozenNodeIterator as FrozenNodeIterator, Profile, Story, StoryItem, TopSearchResults, load_structure_from_file, save_structure_to_file,
resumable_iteration as resumable_iteration) load_structure, get_json_structure)
from .structures import (Hashtag as Hashtag,
Highlight as Highlight,
Post as Post,
PostSidecarNode as PostSidecarNode,
PostComment as PostComment,
PostCommentAnswer as PostCommentAnswer,
PostLocation as PostLocation,
Profile as Profile,
Story as Story,
StoryItem as StoryItem,
TopSearchResults as TopSearchResults,
TitlePic as TitlePic,
load_structure_from_file as load_structure_from_file,
save_structure_to_file as save_structure_to_file,
load_structure as load_structure,
get_json_structure as get_json_structure)
+54 -181
View File
@@ -6,38 +6,23 @@ import os
import re import re
import sys import sys
from argparse import ArgumentParser, ArgumentTypeError, SUPPRESS from argparse import ArgumentParser, ArgumentTypeError, SUPPRESS
from enum import IntEnum
from typing import List, Optional from typing import List, Optional
from . import (AbortDownloadException, BadCredentialsException, Instaloader, InstaloaderException, from . import (AbortDownloadException, BadCredentialsException, Instaloader, InstaloaderException,
InvalidArgumentException, LoginException, Post, Profile, ProfileNotExistsException, StoryItem, InvalidArgumentException, Post, Profile, ProfileNotExistsException, StoryItem,
TwoFactorAuthRequiredException, __version__, load_structure_from_file) TwoFactorAuthRequiredException, __version__, load_structure_from_file)
from .instaloader import (get_default_session_filename, get_default_stamps_filename) from .instaloader import (get_default_session_filename, get_default_stamps_filename)
from .instaloadercontext import default_user_agent from .instaloadercontext import default_user_agent
from .lateststamps import LatestStamps from .lateststamps import LatestStamps
try:
import browser_cookie3
bc3_library = True
except ImportError:
bc3_library = False
class ExitCode(IntEnum):
SUCCESS = 0
NON_FATAL_ERROR = 1
INIT_FAILURE = 2
LOGIN_FAILURE = 3
DOWNLOAD_ABORTED = 4
USER_ABORTED = 5
UNEXPECTED_ERROR = 99
def usage_string(): def usage_string():
# NOTE: duplicated in README.rst and docs/index.rst # NOTE: duplicated in README.rst and docs/index.rst
argv0 = os.path.basename(sys.argv[0]) argv0 = os.path.basename(sys.argv[0])
argv0 = "instaloader" if argv0 == "__main__.py" else argv0 argv0 = "instaloader" if argv0 == "__main__.py" else argv0
return """ return """
{0} [--comments] [--geotags] {0} [--comments] [--geotags]
{2:{1}} [--stories] [--highlights] [--tagged] [--reels] [--igtv] {2:{1}} [--stories] [--highlights] [--tagged] [--igtv]
{2:{1}} [--login YOUR-USERNAME] [--fast-update] {2:{1}} [--login YOUR-USERNAME] [--fast-update]
{2:{1}} profile | "#hashtag" | %%location_id | :stories | :feed | :saved {2:{1}} profile | "#hashtag" | %%location_id | :stories | :feed | :saved
{0} --help""".format(argv0, len(argv0), '') {0} --help""".format(argv0, len(argv0), '')
@@ -82,56 +67,6 @@ def filterstr_to_filterfunc(filter_str: str, item_type: type):
return filterfunc return filterfunc
def get_cookies_from_instagram(domain, browser, cookie_file='', cookie_name=''):
supported_browsers = {
"brave": browser_cookie3.brave,
"chrome": browser_cookie3.chrome,
"chromium": browser_cookie3.chromium,
"edge": browser_cookie3.edge,
"firefox": browser_cookie3.firefox,
"librewolf": browser_cookie3.librewolf,
"opera": browser_cookie3.opera,
"opera_gx": browser_cookie3.opera_gx,
"safari": browser_cookie3.safari,
"vivaldi": browser_cookie3.vivaldi,
}
if browser not in supported_browsers:
raise InvalidArgumentException("Loading cookies from the specified browser failed\n"
"Supported browsers are Brave, Chrome, Chromium, Edge, Firefox, LibreWolf, "
"Opera, Opera_GX, Safari and Vivaldi")
cookies = {}
browser_cookies = list(supported_browsers[browser](cookie_file=cookie_file))
for cookie in browser_cookies:
if domain in cookie.domain:
cookies[cookie.name] = cookie.value
if cookies:
print(f"Cookies loaded successfully from {browser}")
else:
raise LoginException(f"No cookies found for Instagram in {browser}, "
f"Are you logged in succesfully in {browser}?")
if cookie_name:
return cookies.get(cookie_name, {})
else:
return cookies
def import_session(browser, instaloader, cookiefile):
cookie = get_cookies_from_instagram('instagram', browser, cookiefile)
if cookie is not None:
instaloader.context.update_cookies(cookie)
username = instaloader.test_login()
if not username:
raise LoginException(f"Not logged in. Are you logged in successfully in {browser}?")
instaloader.context.username = username
print(f"{username} has been successfully logged in.")
print(f"Next time use --login={username} to reuse the same session.")
def _main(instaloader: Instaloader, targetlist: List[str], def _main(instaloader: Instaloader, targetlist: List[str],
username: Optional[str] = None, password: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None,
sessionfile: Optional[str] = None, sessionfile: Optional[str] = None,
@@ -139,14 +74,11 @@ def _main(instaloader: Instaloader, targetlist: List[str],
download_stories: bool = False, download_stories: bool = False,
download_highlights: bool = False, download_highlights: bool = False,
download_tagged: bool = False, download_tagged: bool = False,
download_reels: bool = False,
download_igtv: bool = False, download_igtv: bool = False,
fast_update: bool = False, fast_update: bool = False,
latest_stamps_file: Optional[str] = None, latest_stamps_file: Optional[str] = None,
max_count: Optional[int] = None, post_filter_str: Optional[str] = None, max_count: Optional[int] = None, post_filter_str: Optional[str] = None,
storyitem_filter_str: Optional[str] = None, storyitem_filter_str: Optional[str] = None) -> None:
browser: Optional[str] = None,
cookiefile: Optional[str] = None) -> ExitCode:
"""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."""
# Parse and generate filter function # Parse and generate filter function
post_filter = None post_filter = None
@@ -161,11 +93,6 @@ def _main(instaloader: Instaloader, targetlist: List[str],
if latest_stamps_file is not None: if latest_stamps_file is not None:
latest_stamps = LatestStamps(latest_stamps_file) latest_stamps = LatestStamps(latest_stamps_file)
instaloader.context.log(f"Using latest stamps from {latest_stamps_file}.") instaloader.context.log(f"Using latest stamps from {latest_stamps_file}.")
# load cookies if browser is not None
if browser and bc3_library:
import_session(browser.lower(), instaloader, cookiefile)
elif browser and not bc3_library:
raise InvalidArgumentException("browser_cookie3 library is needed to load cookies from browsers")
# Login, if desired # Login, if desired
if username is not None: if username is not None:
if not re.match(r"^[A-Za-z0-9._]+$", username): if not re.match(r"^[A-Za-z0-9._]+$", username):
@@ -181,10 +108,6 @@ def _main(instaloader: Instaloader, targetlist: List[str],
try: try:
instaloader.login(username, password) instaloader.login(username, password)
except TwoFactorAuthRequiredException: except TwoFactorAuthRequiredException:
# https://github.com/instaloader/instaloader/issues/1217
instaloader.context.error("Warning: There have been reports of 2FA currently not working. "
"Consider importing session cookies from your browser with "
"--load-cookies.")
while True: while True:
try: try:
code = input("Enter 2FA verification code: ") code = input("Enter 2FA verification code: ")
@@ -194,19 +117,14 @@ def _main(instaloader: Instaloader, targetlist: List[str],
print(err, file=sys.stderr) print(err, file=sys.stderr)
pass pass
else: else:
try: instaloader.interactive_login(username)
instaloader.interactive_login(username)
except KeyboardInterrupt:
print("\nInterrupted by user.", file=sys.stderr)
return ExitCode.USER_ABORTED
instaloader.context.log("Logged in as %s." % username) instaloader.context.log("Logged in as %s." % username)
# since 4.2.9 login is required for geotags # since 4.2.9 login is required for geotags
if instaloader.download_geotags and not instaloader.context.is_logged_in: if instaloader.download_geotags and not instaloader.context.is_logged_in:
instaloader.context.error("Warning: Login is required to download geotags of posts.") instaloader.context.error("Warning: Use --login to download geotags of posts.")
# Try block for KeyboardInterrupt (save session on ^C) # Try block for KeyboardInterrupt (save session on ^C)
profiles = set() profiles = set()
anonymous_retry_profiles = set() anonymous_retry_profiles = set()
exit_code = ExitCode.SUCCESS
try: try:
# Generate set of profiles, already downloading non-profile targets # Generate set of profiles, already downloading non-profile targets
for target in targetlist: for target in targetlist:
@@ -259,13 +177,11 @@ 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)
elif re.match(r"^[A-Za-z0-9._]+$", target): elif re.match(r"^[A-Za-z0-9._]+$", target):
download_profile_content = download_posts or download_tagged or download_reels or download_igtv
try: try:
profile = instaloader.check_profile_id(target, latest_stamps) profile = instaloader.check_profile_id(target, latest_stamps)
if instaloader.context.is_logged_in and profile.has_blocked_viewer: if instaloader.context.is_logged_in and profile.has_blocked_viewer:
if download_profile_pic or ( if download_profile_pic or ((download_posts or download_tagged or download_igtv)
download_profile_content and not profile.is_private and not profile.is_private):
):
raise ProfileNotExistsException("{} blocked you; But we download her anonymously." raise ProfileNotExistsException("{} blocked you; But we download her anonymously."
.format(target)) .format(target))
else: else:
@@ -275,7 +191,8 @@ def _main(instaloader: Instaloader, targetlist: List[str],
except ProfileNotExistsException as err: except ProfileNotExistsException as err:
# Not only our profile.has_blocked_viewer condition raises ProfileNotExistsException, # Not only our profile.has_blocked_viewer condition raises ProfileNotExistsException,
# check_profile_id() also does, since access to blocked profile may be responded with 404. # check_profile_id() also does, since access to blocked profile may be responded with 404.
if instaloader.context.is_logged_in and (download_profile_pic or download_profile_content): if instaloader.context.is_logged_in and (download_profile_pic or download_posts or
download_tagged or download_igtv):
instaloader.context.log(err) instaloader.context.log(err)
instaloader.context.log("Trying again anonymously, helps in case you are just blocked.") instaloader.context.log("Trying again anonymously, helps in case you are just blocked.")
with instaloader.anonymous_copy() as anonymous_loader: with instaloader.anonymous_copy() as anonymous_loader:
@@ -298,42 +215,23 @@ def _main(instaloader: Instaloader, targetlist: List[str],
' '.join([p.username for p in profiles]))) ' '.join([p.username for p in profiles])))
if instaloader.context.iphone_support and profiles and (download_profile_pic or download_posts) and \ if instaloader.context.iphone_support and profiles and (download_profile_pic or download_posts) and \
not instaloader.context.is_logged_in: not instaloader.context.is_logged_in:
instaloader.context.log("Hint: Login to download higher-quality versions of pictures.") instaloader.context.log("Hint: Use --login to download higher-quality versions of pictures.")
instaloader.download_profiles( instaloader.download_profiles(profiles,
profiles, download_profile_pic, download_posts, download_tagged, download_igtv,
download_profile_pic, download_highlights, download_stories,
download_posts, fast_update, post_filter, storyitem_filter, latest_stamps=latest_stamps)
download_tagged,
download_igtv,
download_highlights,
download_stories,
fast_update,
post_filter,
storyitem_filter,
latest_stamps=latest_stamps,
reels=download_reels,
)
if anonymous_retry_profiles: if anonymous_retry_profiles:
instaloader.context.log("Downloading anonymously: {}" instaloader.context.log("Downloading anonymously: {}"
.format(' '.join([p.username for p in anonymous_retry_profiles]))) .format(' '.join([p.username for p in anonymous_retry_profiles])))
with instaloader.anonymous_copy() as anonymous_loader: with instaloader.anonymous_copy() as anonymous_loader:
anonymous_loader.download_profiles( anonymous_loader.download_profiles(anonymous_retry_profiles,
anonymous_retry_profiles, download_profile_pic, download_posts, download_tagged, download_igtv,
download_profile_pic, fast_update=fast_update, post_filter=post_filter,
download_posts, latest_stamps=latest_stamps)
download_tagged,
download_igtv,
fast_update=fast_update,
post_filter=post_filter,
latest_stamps=latest_stamps,
reels=download_reels
)
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nInterrupted by user.", file=sys.stderr) print("\nInterrupted by user.", file=sys.stderr)
exit_code = ExitCode.USER_ABORTED
except AbortDownloadException as exc: except AbortDownloadException as exc:
print("\nDownload aborted: {}.".format(exc), file=sys.stderr) print("\nDownload aborted: {}.".format(exc), file=sys.stderr)
exit_code = ExitCode.DOWNLOAD_ABORTED
# Save session if it is useful # Save session if it is useful
if instaloader.context.is_logged_in: if instaloader.context.is_logged_in:
instaloader.save_session_to_file(sessionfile) instaloader.save_session_to_file(sessionfile)
@@ -345,8 +243,6 @@ def _main(instaloader: Instaloader, targetlist: List[str],
else: else:
# Instaloader did not do anything # Instaloader did not do anything
instaloader.context.log("usage:" + usage_string()) instaloader.context.log("usage:" + usage_string())
exit_code = ExitCode.INIT_FAILURE
return exit_code
def main(): def main():
@@ -362,17 +258,17 @@ def main():
help="Download profile. If an already-downloaded profile has been renamed, Instaloader " help="Download profile. If an already-downloaded profile has been renamed, Instaloader "
"automatically finds it by its unique ID and renames the folder likewise.") "automatically finds it by its unique ID and renames the folder likewise.")
g_targets.add_argument('_at_profile', nargs='*', metavar="@profile", g_targets.add_argument('_at_profile', nargs='*', metavar="@profile",
help="Download all followees of profile. Requires login. " help="Download all followees of profile. Requires --login. "
"Consider using :feed rather than @yourself.") "Consider using :feed rather than @yourself.")
g_targets.add_argument('_hashtag', nargs='*', metavar='"#hashtag"', help="Download #hashtag.") g_targets.add_argument('_hashtag', nargs='*', metavar='"#hashtag"', help="Download #hashtag.")
g_targets.add_argument('_location', nargs='*', metavar='%location_id', g_targets.add_argument('_location', nargs='*', metavar='%location_id',
help="Download %%location_id. Requires login.") help="Download %%location_id. Requires --login.")
g_targets.add_argument('_feed', nargs='*', metavar=":feed", g_targets.add_argument('_feed', nargs='*', metavar=":feed",
help="Download pictures from your feed. Requires login.") help="Download pictures from your feed. Requires --login.")
g_targets.add_argument('_stories', nargs='*', metavar=":stories", g_targets.add_argument('_stories', nargs='*', metavar=":stories",
help="Download the stories of your followees. Requires login.") help="Download the stories of your followees. Requires --login.")
g_targets.add_argument('_saved', nargs='*', metavar=":saved", g_targets.add_argument('_saved', nargs='*', metavar=":saved",
help="Download the posts that you marked as saved. Requires login.") help="Download the posts that you marked as saved. Requires --login.")
g_targets.add_argument('_singlepost', nargs='*', metavar="-- -shortcode", g_targets.add_argument('_singlepost', nargs='*', metavar="-- -shortcode",
help="Download the post with the given shortcode") help="Download the post with the given shortcode")
g_targets.add_argument('_json', nargs='*', metavar="filename.json[.xz]", g_targets.add_argument('_json', nargs='*', metavar="filename.json[.xz]",
@@ -403,11 +299,11 @@ def main():
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. '
'This requires an additional request to the Instagram ' 'This requires an additional request to the Instagram '
'server for each picture. Requires login.') 'server for each picture. Requires --login.')
g_post.add_argument('-C', '--comments', action='store_true', g_post.add_argument('-C', '--comments', action='store_true',
help='Download and update comments for each post. ' help='Download and update comments for each post. '
'This requires an additional request to the Instagram ' 'This requires an additional request to the Instagram '
'server for each post, which is why it is disabled by default. Requires login.') 'server for each post, which is why it is disabled by default.')
g_post.add_argument('--no-captions', action='store_true', g_post.add_argument('--no-captions', action='store_true',
help='Do not create txt files.') help='Do not create txt files.')
g_post.add_argument('--post-metadata-txt', action='append', g_post.add_argument('--post-metadata-txt', action='append',
@@ -421,15 +317,13 @@ def main():
g_post.add_argument('--no-compress-json', action='store_true', g_post.add_argument('--no-compress-json', action='store_true',
help='Do not xz compress JSON files, rather create pretty formatted JSONs.') help='Do not xz compress JSON files, rather create pretty formatted JSONs.')
g_prof.add_argument('-s', '--stories', action='store_true', g_prof.add_argument('-s', '--stories', action='store_true',
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_prof.add_argument('--stories-only', action='store_true', g_prof.add_argument('--stories-only', action='store_true',
help=SUPPRESS) help=SUPPRESS)
g_prof.add_argument('--highlights', action='store_true', g_prof.add_argument('--highlights', action='store_true',
help='Also download highlights of each profile that is downloaded. Requires login.') help='Also download highlights of each profile that is downloaded. Requires --login.')
g_prof.add_argument('--tagged', action='store_true', g_prof.add_argument('--tagged', action='store_true',
help='Also download posts where each profile is tagged.') help='Also download posts where each profile is tagged.')
g_prof.add_argument('--reels', action='store_true',
help='Also download Reels videos.')
g_prof.add_argument('--igtv', action='store_true', g_prof.add_argument('--igtv', action='store_true',
help='Also download IGTV videos.') help='Also download IGTV videos.')
@@ -459,14 +353,9 @@ def main():
'Instaloader can login to Instagram. This allows downloading private profiles. ' 'Instaloader can login to Instagram. This allows downloading private profiles. '
'To login, pass the --login option. Your session cookie (not your password!) ' 'To login, pass the --login option. Your session cookie (not your password!) '
'will be saved to a local file to be reused next time you want Instaloader ' 'will be saved to a local file to be reused next time you want Instaloader '
'to login. Instead of --login, the --load-cookies option can be used to ' 'to login.')
'import a session from a browser.')
g_login.add_argument('-l', '--login', metavar='YOUR-USERNAME', g_login.add_argument('-l', '--login', metavar='YOUR-USERNAME',
help='Login name (profile name) for your Instagram account.') help='Login name (profile name) for your Instagram account.')
g_login.add_argument('-b', '--load-cookies', metavar='BROWSER-NAME',
help='Browser name to load cookies from Instagram')
g_login.add_argument('-B', '--cookiefile', metavar='COOKIE-FILE',
help='Cookie file of a profile to load cookies')
g_login.add_argument('-f', '--sessionfile', g_login.add_argument('-f', '--sessionfile',
help='Path for loading and storing session key file. ' help='Path for loading and storing session key file. '
'Defaults to ' + get_default_session_filename("<login_name>")) 'Defaults to ' + get_default_session_filename("<login_name>"))
@@ -527,15 +416,15 @@ def main():
args = parser.parse_args() args = parser.parse_args()
try: try:
if (args.login is None and args.load_cookies is None) and (args.stories or args.stories_only): if args.login is None and (args.stories or args.stories_only):
print("Login is required to download stories.", file=sys.stderr) print("--login=USERNAME required to download stories.", file=sys.stderr)
args.stories = False args.stories = False
if args.stories_only: if args.stories_only:
raise InvalidArgumentException() raise SystemExit(1)
if ':feed-all' in args.profile or ':feed-liked' in args.profile: if ':feed-all' in args.profile or ':feed-liked' in args.profile:
raise InvalidArgumentException(":feed-all and :feed-liked were removed. Use :feed as target and " raise SystemExit(":feed-all and :feed-liked were removed. Use :feed as target and "
"eventually --post-filter=viewer_has_liked.") "eventually --post-filter=viewer_has_liked.")
post_metadata_txt_pattern = '\n'.join(args.post_metadata_txt) if args.post_metadata_txt else None post_metadata_txt_pattern = '\n'.join(args.post_metadata_txt) if args.post_metadata_txt else None
storyitem_metadata_txt_pattern = '\n'.join(args.storyitem_metadata_txt) if args.storyitem_metadata_txt else None storyitem_metadata_txt_pattern = '\n'.join(args.storyitem_metadata_txt) if args.storyitem_metadata_txt else None
@@ -545,18 +434,15 @@ def main():
post_metadata_txt_pattern = '' post_metadata_txt_pattern = ''
storyitem_metadata_txt_pattern = '' storyitem_metadata_txt_pattern = ''
else: else:
raise InvalidArgumentException("--no-captions and --post-metadata-txt or --storyitem-metadata-txt " raise SystemExit("--no-captions and --post-metadata-txt or --storyitem-metadata-txt given; "
"given; That contradicts.") "That contradicts.")
if args.no_resume and args.resume_prefix: if args.no_resume and args.resume_prefix:
raise InvalidArgumentException("--no-resume and --resume-prefix given; That contradicts.") raise SystemExit("--no-resume and --resume-prefix given; That contradicts.")
resume_prefix = (args.resume_prefix if args.resume_prefix else 'iterator') if not args.no_resume else None resume_prefix = (args.resume_prefix if args.resume_prefix else 'iterator') if not args.no_resume else None
if args.no_pictures and args.fast_update: if args.no_pictures and args.fast_update:
raise InvalidArgumentException('--no-pictures and --fast-update cannot be used together.') raise SystemExit('--no-pictures and --fast-update cannot be used together.')
if args.login and args.load_cookies:
raise InvalidArgumentException('--load-cookies and --login cannot be used together.')
# Determine what to download # Determine what to download
download_profile_pic = not args.no_profile_pic or args.profile_pic_only download_profile_pic = not args.no_profile_pic or args.profile_pic_only
@@ -581,38 +467,25 @@ def main():
iphone_support=not args.no_iphone, iphone_support=not args.no_iphone,
title_pattern=args.title_pattern, title_pattern=args.title_pattern,
sanitize_paths=args.sanitize_paths) sanitize_paths=args.sanitize_paths)
exit_code = _main(loader, _main(loader,
args.profile, args.profile,
username=args.login.lower() if args.login is not None else None, username=args.login.lower() if args.login is not None else None,
password=args.password, password=args.password,
sessionfile=args.sessionfile, sessionfile=args.sessionfile,
download_profile_pic=download_profile_pic, download_profile_pic=download_profile_pic,
download_posts=download_posts, download_posts=download_posts,
download_stories=download_stories, download_stories=download_stories,
download_highlights=args.highlights, download_highlights=args.highlights,
download_tagged=args.tagged, download_tagged=args.tagged,
download_reels=args.reels, download_igtv=args.igtv,
download_igtv=args.igtv, fast_update=args.fast_update,
fast_update=args.fast_update, latest_stamps_file=args.latest_stamps,
latest_stamps_file=args.latest_stamps, max_count=int(args.count) if args.count is not None else None,
max_count=int(args.count) if args.count is not None else None, post_filter_str=args.post_filter,
post_filter_str=args.post_filter, storyitem_filter_str=args.storyitem_filter)
storyitem_filter_str=args.storyitem_filter,
browser=args.load_cookies,
cookiefile=args.cookiefile)
loader.close() loader.close()
if loader.has_stored_errors:
exit_code = ExitCode.NON_FATAL_ERROR
except InvalidArgumentException as err:
print(err, file=sys.stderr)
exit_code = ExitCode.INIT_FAILURE
except LoginException as err:
print(err, file=sys.stderr)
exit_code = ExitCode.LOGIN_FAILURE
except InstaloaderException as err: except InstaloaderException as err:
print("Fatal error: %s" % err) raise SystemExit("Fatal error: %s" % err) from err
exit_code = ExitCode.UNEXPECTED_ERROR
sys.exit(exit_code)
if __name__ == "__main__": if __name__ == "__main__":
+2 -6
View File
@@ -33,11 +33,7 @@ class LoginRequiredException(InstaloaderException):
pass pass
class LoginException(InstaloaderException): class TwoFactorAuthRequiredException(InstaloaderException):
pass
class TwoFactorAuthRequiredException(LoginException):
pass pass
@@ -49,7 +45,7 @@ class BadResponseException(InstaloaderException):
pass pass
class BadCredentialsException(LoginException): class BadCredentialsException(InstaloaderException):
pass pass
+17 -82
View File
@@ -77,7 +77,7 @@ def _requires_login(func: Callable) -> Callable:
@wraps(func) @wraps(func)
def call(instaloader, *args, **kwargs): def call(instaloader, *args, **kwargs):
if not instaloader.context.is_logged_in: if not instaloader.context.is_logged_in:
raise LoginRequiredException("Login required.") raise LoginRequiredException("--login=USERNAME required.")
return func(instaloader, *args, **kwargs) return func(instaloader, *args, **kwargs)
return call return call
@@ -643,16 +643,11 @@ class Instaloader:
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 BadCredentialsException: If the provided password is wrong.
:raises ConnectionException: If connection to Instagram failed.
:raises TwoFactorAuthRequiredException: First step of 2FA login done, now call :raises TwoFactorAuthRequiredException: First step of 2FA login done, now call
:meth:`Instaloader.two_factor_login`. :meth:`Instaloader.two_factor_login`."""
:raises LoginException: An error happened during login (for example, an invalid response was received).
Or if the provided username does not exist.
.. versionchanged:: 4.12
Raises LoginException instead of ConnectionException when an error happens.
Raises LoginException instead of InvalidArgumentException when the username does not exist.
"""
self.context.login(user, passwd) self.context.login(user, passwd)
def two_factor_login(self, two_factor_code) -> None: def two_factor_login(self, two_factor_code) -> None:
@@ -722,7 +717,7 @@ class Instaloader:
# 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 = True downloaded = True
if post.typename == 'GraphSidecar': if post.typename == 'GraphSidecar':
if (self.download_pictures or self.download_videos) and post.mediacount > 0: if self.download_pictures or self.download_videos:
if not _all_already_downloaded( if not _all_already_downloaded(
filename_template, enumerate( filename_template, enumerate(
(post.get_is_videos()[i] (post.get_is_videos()[i]
@@ -866,9 +861,8 @@ class Instaloader:
last_scraped = latest_stamps.get_last_story_timestamp(name) last_scraped = latest_stamps.get_last_story_timestamp(name)
scraped_timestamp = datetime.now().astimezone() scraped_timestamp = datetime.now().astimezone()
for item in user_story.get_items(): for item in user_story.get_items():
if latest_stamps is not None: if latest_stamps is not None and item.date_local <= last_scraped:
if item.date_local <= last_scraped: break
break
if storyitem_filter is not None and not storyitem_filter(item): if storyitem_filter is not None and not storyitem_filter(item):
self.context.log("<{} skipped>".format(item), flush=True) self.context.log("<{} skipped>".format(item), flush=True)
continue continue
@@ -997,8 +991,7 @@ class Instaloader:
max_count: Optional[int] = None, max_count: Optional[int] = None,
total_count: Optional[int] = None, total_count: Optional[int] = None,
owner_profile: Optional[Profile] = None, owner_profile: Optional[Profile] = None,
takewhile: Optional[Callable[[Post], bool]] = None, takewhile: Optional[Callable[[Post], bool]] = None) -> None:
possibly_pinned: int = 0) -> None:
""" """
Download the Posts returned by given Post Iterator. Download the Posts returned by given Post Iterator.
@@ -1010,9 +1003,6 @@ class Instaloader:
.. versionchanged:: 4.8 .. versionchanged:: 4.8
Add `takewhile` parameter. Add `takewhile` parameter.
.. versionchanged:: 4.10.3
Add `possibly_pinned` parameter.
:param posts: Post Iterator to loop through. :param posts: Post Iterator to loop through.
:param target: Target name. :param target: Target name.
:param fast_update: :option:`--fast-update`. :param fast_update: :option:`--fast-update`.
@@ -1021,8 +1011,6 @@ class Instaloader:
:param total_count: Total number of posts returned by given iterator. :param total_count: Total number of posts returned by given iterator.
:param owner_profile: Associated profile, if any. :param owner_profile: Associated profile, if any.
:param takewhile: Expression evaluated for each post. Once it returns false, downloading stops. :param takewhile: Expression evaluated for each post. Once it returns false, downloading stops.
:param possibly_pinned: Number of posts that might be pinned. These posts do not cause download
to stop even if they've already been downloaded.
""" """
displayed_count = (max_count if total_count is None or max_count is not None and max_count < total_count displayed_count = (max_count if total_count is None or max_count is not None and max_count < total_count
else total_count) else total_count)
@@ -1044,7 +1032,7 @@ class Instaloader:
) as (is_resuming, start_index): ) as (is_resuming, start_index):
for number, post in enumerate(posts, start=start_index + 1): for number, post in enumerate(posts, start=start_index + 1):
should_stop = not takewhile(post) should_stop = not takewhile(post)
if should_stop and number <= possibly_pinned: if should_stop and post.is_pinned:
continue continue
if (max_count is not None and number > max_count) or should_stop: if (max_count is not None and number > max_count) or should_stop:
break break
@@ -1078,7 +1066,7 @@ class Instaloader:
except PostChangedException: except PostChangedException:
post_changed = True post_changed = True
continue continue
if fast_update and not downloaded and not post_changed and number > possibly_pinned: if fast_update and not downloaded and not post_changed and not post.is_pinned:
# disengage fast_update for first post when resuming # disengage fast_update for first post when resuming
if not is_resuming or number > 0: if not is_resuming or number > 0:
break break
@@ -1281,31 +1269,6 @@ class Instaloader:
if latest_stamps is not None and tagged_posts.first_item is not None: if latest_stamps is not None and tagged_posts.first_item is not None:
latest_stamps.set_last_tagged_timestamp(profile.username, tagged_posts.first_item.date_local) latest_stamps.set_last_tagged_timestamp(profile.username, tagged_posts.first_item.date_local)
def download_reels(self, profile: Profile, fast_update: bool = False,
post_filter: Optional[Callable[[Post], bool]] = None,
latest_stamps: Optional[LatestStamps] = None) -> None:
"""Download reels videos of a profile.
.. versionadded:: 4.14.0
"""
self.context.log("Retrieving reels videos for profile {}.".format(profile.username))
posts_takewhile: Optional[Callable[[Post], bool]] = None
if latest_stamps is not None:
last_scraped = latest_stamps.get_last_reels_timestamp(profile.username)
posts_takewhile = lambda p: p.date_local > last_scraped
reels = profile.get_reels()
self.posts_download_loop(
reels,
profile.username,
fast_update,
post_filter,
owner_profile=profile,
takewhile=posts_takewhile,
)
if latest_stamps is not None and reels.first_item is not None:
latest_stamps.set_last_reels_timestamp(profile.username, reels.first_item.date_local)
def download_igtv(self, profile: Profile, fast_update: bool = False, def download_igtv(self, profile: Profile, fast_update: bool = False,
post_filter: Optional[Callable[[Post], bool]] = None, post_filter: Optional[Callable[[Post], bool]] = None,
latest_stamps: Optional[LatestStamps] = None) -> None: latest_stamps: Optional[LatestStamps] = None) -> None:
@@ -1435,9 +1398,7 @@ class Instaloader:
post_filter: Optional[Callable[[Post], bool]] = None, post_filter: Optional[Callable[[Post], bool]] = None,
storyitem_filter: Optional[Callable[[Post], bool]] = None, storyitem_filter: Optional[Callable[[Post], bool]] = None,
raise_errors: bool = False, raise_errors: bool = False,
latest_stamps: Optional[LatestStamps] = None, latest_stamps: Optional[LatestStamps] = None):
max_count: Optional[int] = None,
reels: bool = False):
"""High-level method to download set of profiles. """High-level method to download set of profiles.
:param profiles: Set of profiles to download. :param profiles: Set of profiles to download.
@@ -1454,8 +1415,6 @@ class Instaloader:
Whether :exc:`LoginRequiredException` and :exc:`PrivateProfileNotFollowedException` should be raised or Whether :exc:`LoginRequiredException` and :exc:`PrivateProfileNotFollowedException` should be raised or
catched and printed with :meth:`InstaloaderContext.error_catcher`. catched and printed with :meth:`InstaloaderContext.error_catcher`.
:param latest_stamps: :option:`--latest-stamps`. :param latest_stamps: :option:`--latest-stamps`.
:param max_count: Maximum count of posts to download.
:param reels: :option:`--reels`.
.. versionadded:: 4.1 .. versionadded:: 4.1
@@ -1464,12 +1423,6 @@ class Instaloader:
.. versionchanged:: 4.8 .. versionchanged:: 4.8
Add `latest_stamps` parameter. Add `latest_stamps` parameter.
.. versionchanged:: 4.13
Add `max_count` parameter.
.. versionchanged:: 4.14
Add `reels` parameter.
""" """
@contextmanager @contextmanager
@@ -1501,7 +1454,7 @@ class Instaloader:
if tagged or igtv or highlights or posts: if tagged or igtv or highlights or posts:
if (not self.context.is_logged_in and if (not self.context.is_logged_in and
profile.is_private): profile.is_private):
raise LoginRequiredException("Login required.") raise LoginRequiredException("--login=USERNAME required.")
if (self.context.username != profile.username and if (self.context.username != profile.username and
profile.is_private and profile.is_private and
not profile.followed_by_viewer): not profile.followed_by_viewer):
@@ -1513,12 +1466,6 @@ class Instaloader:
self.download_tagged(profile, fast_update=fast_update, post_filter=post_filter, self.download_tagged(profile, fast_update=fast_update, post_filter=post_filter,
latest_stamps=latest_stamps) latest_stamps=latest_stamps)
# Download reels, if requested
if reels:
with self.context.error_catcher('Download reels of {}'.format(profile_name)):
self.download_reels(profile, fast_update=fast_update, post_filter=post_filter,
latest_stamps=latest_stamps)
# Download IGTV, if requested # Download IGTV, if requested
if igtv: if igtv:
with self.context.error_catcher('Download IGTV of {}'.format(profile_name)): with self.context.error_catcher('Download IGTV of {}'.format(profile_name)):
@@ -1541,7 +1488,7 @@ class Instaloader:
posts_to_download = profile.get_posts() posts_to_download = profile.get_posts()
self.posts_download_loop(posts_to_download, profile_name, fast_update, post_filter, self.posts_download_loop(posts_to_download, profile_name, fast_update, post_filter,
total_count=profile.mediacount, owner_profile=profile, total_count=profile.mediacount, owner_profile=profile,
takewhile=posts_takewhile, possibly_pinned=3, max_count=max_count) takewhile=posts_takewhile)
if latest_stamps is not None and posts_to_download.first_item is not None: if latest_stamps is not None and posts_to_download.first_item is not None:
latest_stamps.set_last_post_timestamp(profile_name, latest_stamps.set_last_post_timestamp(profile_name,
posts_to_download.first_item.date_local) posts_to_download.first_item.date_local)
@@ -1629,16 +1576,11 @@ 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 InvalidArgumentException: when in quiet mode. :raises LoginRequiredException: when in quiet mode.
:raises LoginException: If the provided username does not exist. :raises InvalidArgumentException: If the provided username does not exist.
:raises ConnectionException: If connection to Instagram failed. :raises ConnectionException: If connection to Instagram failed."""
.. versionchanged:: 4.12
Raises InvalidArgumentException instead of LoginRequiredException when in quiet mode.
Raises LoginException instead of InvalidArgumentException when the username does not exist.
"""
if self.context.quiet: if self.context.quiet:
raise InvalidArgumentException("Quiet mode requires given password or valid session file.") raise LoginRequiredException("Quiet mode requires given password or valid session file.")
try: try:
password = None password = None
while password is None: while password is None:
@@ -1657,10 +1599,3 @@ class Instaloader:
except BadCredentialsException as err: except BadCredentialsException as err:
print(err, file=sys.stderr) print(err, file=sys.stderr)
pass pass
@property
def has_stored_errors(self) -> bool:
"""Returns whether any error has been reported and stored to be repeated at program termination.
.. versionadded: 4.12"""
return self.context.has_stored_errors
+92 -139
View File
@@ -1,14 +1,16 @@
import hashlib
import json import json
import os import os
import pickle import pickle
import random import random
import re
import shutil import shutil
import sys import sys
import textwrap import textwrap
import time import time
import urllib.parse import urllib.parse
import uuid import uuid
from contextlib import contextmanager, suppress from contextlib import contextmanager
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import partial from functools import partial
from typing import Any, Callable, Dict, Iterator, List, Optional, Union from typing import Any, Callable, Dict, Iterator, List, Optional, Union
@@ -31,8 +33,8 @@ def copy_session(session: requests.Session, request_timeout: Optional[float] = N
def default_user_agent() -> str: def default_user_agent() -> str:
return ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' return 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' \
'(KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36') '(KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36'
def default_iphone_headers() -> Dict[str, Any]: def default_iphone_headers() -> Dict[str, Any]:
@@ -93,6 +95,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._root_rhx_gis = None
self.two_factor_auth_pending = None self.two_factor_auth_pending = None
self.iphone_support = iphone_support self.iphone_support = iphone_support
self.iphone_headers = default_iphone_headers() self.iphone_headers = default_iphone_headers()
@@ -149,13 +152,6 @@ class InstaloaderContext:
if repeat_at_end: if repeat_at_end:
self.error_log.append(msg) self.error_log.append(msg)
@property
def has_stored_errors(self) -> bool:
"""Returns whether any error has been reported and stored to be repeated at program termination.
.. versionadded: 4.12"""
return bool(self.error_log)
def close(self): def close(self):
"""Print error log and close session""" """Print error log and close session"""
if self.error_log and not self.quiet: if self.error_log and not self.quiet:
@@ -215,10 +211,6 @@ class InstaloaderContext:
"""Not meant to be used directly, use :meth:`Instaloader.save_session`.""" """Not meant to be used directly, use :meth:`Instaloader.save_session`."""
return requests.utils.dict_from_cookiejar(self._session.cookies) return requests.utils.dict_from_cookiejar(self._session.cookies)
def update_cookies(self, cookie):
""".. versionadded:: 4.11"""
self._session.cookies.update(cookie)
def load_session(self, username, sessiondata): def load_session(self, username, sessiondata):
"""Not meant to be used directly, use :meth:`Instaloader.load_session`.""" """Not meant to be used directly, use :meth:`Instaloader.load_session`."""
session = requests.Session() session = requests.Session()
@@ -241,26 +233,17 @@ class InstaloaderContext:
def test_login(self) -> Optional[str]: def test_login(self) -> Optional[str]:
"""Not meant to be used directly, use :meth:`Instaloader.test_login`.""" """Not meant to be used directly, use :meth:`Instaloader.test_login`."""
try: data = self.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {})
data = self.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {}) 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
except (AbortDownloadException, ConnectionException) as err:
self.error(f"Error when checking if logged in: {err}")
return 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 BadCredentialsException: If the provided password is wrong.
:raises ConnectionException: If connection to Instagram failed.
:raises TwoFactorAuthRequiredException: First step of 2FA login done, now call :raises TwoFactorAuthRequiredException: First step of 2FA login done, now call
:meth:`Instaloader.two_factor_login`. :meth:`Instaloader.two_factor_login`."""
:raises LoginException: An error happened during login (for example, and invalid response).
Or if the provided username does not exist.
.. versionchanged:: 4.12
Raises LoginException instead of ConnectionException when an error happens.
Raises LoginException instead of InvalidArgumentException when the username does not exist.
"""
# pylint:disable=import-outside-toplevel # pylint:disable=import-outside-toplevel
import http.client import http.client
# pylint:disable=protected-access # pylint:disable=protected-access
@@ -270,28 +253,25 @@ class InstaloaderContext:
'ig_vw': '1920', 'ig_cb': '1', 'csrftoken': '', 'ig_vw': '1920', 'ig_cb': '1', '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())
# Getting mid cookie value
session.get("https://i.instagram.com/api/v1/web/login_page/")
# Override default timeout behavior. # Override default timeout behavior.
# Need to silence mypy bug for this. See: https://github.com/python/mypy/issues/2427 # Need to silence mypy bug for this. See: https://github.com/python/mypy/issues/2427
session.request = partial(session.request, timeout=self.request_timeout) # type: ignore session.request = partial(session.request, timeout=self.request_timeout) # type: ignore
csrf_json = self.get_json('accounts/login/', {}, session=session)
# Make a request to Instagram's root URL, which will set the session's csrftoken cookie csrf_token = csrf_json['config']['csrf_token']
# Not using self.get_json() here, because we need to access the cookie
session.get('https://www.instagram.com/')
# Add session's csrftoken cookie to session headers
csrf_token = session.cookies.get_dict()['csrftoken']
session.headers.update({'X-CSRFToken': csrf_token}) session.headers.update({'X-CSRFToken': csrf_token})
# Not using self.get_json() here, because we need to access csrftoken cookie
self.do_sleep() self.do_sleep()
# Workaround credits to pgrimaud. # Workaround credits to pgrimaud.
# See: https://github.com/pgrimaud/instagram-user-feed/commit/96ad4cf54d1ad331b337f325c73e664999a6d066 # See: https://github.com/pgrimaud/instagram-user-feed/commit/96ad4cf54d1ad331b337f325c73e664999a6d066
enc_password = '#PWD_INSTAGRAM_BROWSER:0:{}:{}'.format(int(datetime.now().timestamp()), passwd) enc_password = '#PWD_INSTAGRAM_BROWSER:0:{}:{}'.format(int(datetime.now().timestamp()), passwd)
login = session.post('https://www.instagram.com/api/v1/web/accounts/login/ajax/', login = session.post('https://www.instagram.com/accounts/login/ajax/',
data={'enc_password': enc_password, 'username': user}, allow_redirects=True) data={'enc_password': enc_password, 'username': user}, allow_redirects=True)
try: try:
resp_json = login.json() resp_json = login.json()
except json.decoder.JSONDecodeError as err: except json.decoder.JSONDecodeError as err:
raise LoginException( raise ConnectionException(
"Login error: JSON decode fail, {} - {}.".format(login.status_code, login.reason) "Login error: JSON decode fail, {} - {}.".format(login.status_code, login.reason)
) from err ) from err
if resp_json.get('two_factor_required'): if resp_json.get('two_factor_required'):
@@ -303,34 +283,34 @@ class InstaloaderContext:
resp_json['two_factor_info']['two_factor_identifier']) resp_json['two_factor_info']['two_factor_identifier'])
raise TwoFactorAuthRequiredException("Login error: two-factor authentication required.") raise TwoFactorAuthRequiredException("Login error: two-factor authentication required.")
if resp_json.get('checkpoint_url'): if resp_json.get('checkpoint_url'):
raise LoginException( raise ConnectionException("Login: Checkpoint required. Point your browser to "
f"Login: Checkpoint required. Point your browser to {resp_json.get('checkpoint_url')} - " "https://www.instagram.com{} - "
f"follow the instructions, then retry." "follow the instructions, then retry.".format(resp_json.get('checkpoint_url')))
)
if resp_json['status'] != 'ok': if resp_json['status'] != 'ok':
if 'message' in resp_json: if 'message' in resp_json:
raise LoginException("Login error: \"{}\" status, message \"{}\".".format(resp_json['status'], raise ConnectionException("Login error: \"{}\" status, message \"{}\".".format(resp_json['status'],
resp_json['message'])) resp_json['message']))
else: else:
raise LoginException("Login error: \"{}\" status.".format(resp_json['status'])) raise ConnectionException("Login error: \"{}\" status.".format(resp_json['status']))
if 'authenticated' not in resp_json: if 'authenticated' not in resp_json:
# Issue #472 # Issue #472
if 'message' in resp_json: if 'message' in resp_json:
raise LoginException("Login error: Unexpected response, \"{}\".".format(resp_json['message'])) raise ConnectionException("Login error: Unexpected response, \"{}\".".format(resp_json['message']))
else: else:
raise LoginException("Login error: Unexpected response, this might indicate a blocked IP.") raise ConnectionException("Login error: Unexpected response, this might indicate a blocked IP.")
if not resp_json['authenticated']: if not resp_json['authenticated']:
if resp_json['user']: if resp_json['user']:
# '{"authenticated": false, "user": true, "status": "ok"}' # '{"authenticated": false, "user": true, "status": "ok"}'
raise BadCredentialsException('Login error: Wrong password.') raise BadCredentialsException('Login error: Wrong password.')
else: else:
# '{"authenticated": false, "user": false, "status": "ok"}' # '{"authenticated": false, "user": false, "status": "ok"}'
# Raise LoginException rather than BadCredentialException, because BadCredentialException # Raise InvalidArgumentException rather than BadCredentialException, because BadCredentialException
# triggers re-asking of password in Instaloader.interactive_login(), which makes no sense if the # triggers re-asking of password in Instaloader.interactive_login(), which makes no sense if the
# username is invalid. # username is invalid.
raise LoginException('Login error: User {} does not exist.'.format(user)) raise InvalidArgumentException('Login error: User {} does not exist.'.format(user))
# '{"authenticated": true, "user": true, "userId": ..., "oneTapPrompt": false, "status": "ok"}' # '{"authenticated": true, "user": true, "userId": ..., "oneTapPrompt": false, "status": "ok"}'
session.headers.update({'X-CSRFToken': login.cookies['csrftoken']}) if 'csrftoken' in login.cookies:
session.headers.update({'X-CSRFToken': login.cookies['csrftoken']})
self._session = session self._session = session
self.username = user self.username = user
self.user_id = resp_json['userId'] self.user_id = resp_json['userId']
@@ -347,8 +327,15 @@ class InstaloaderContext:
raise InvalidArgumentException("No two-factor authentication pending.") raise InvalidArgumentException("No two-factor authentication pending.")
(session, user, two_factor_id) = self.two_factor_auth_pending (session, user, two_factor_id) = self.two_factor_auth_pending
login = session.post('https://www.instagram.com/accounts/login/ajax/two_factor/', login = session.post('https://www.instagram.com/api/v1/web/accounts/login/ajax/two_factor/',
data={'username': user, 'verificationCode': two_factor_code, 'identifier': two_factor_id}, data={
'identifier': two_factor_id,
'queryParams': '{"next":"/"}',
'trust_signal': 'true',
'username': user,
'verification_method': '1',
'verificationCode': two_factor_code,
},
allow_redirects=True) allow_redirects=True)
resp_json = login.json() resp_json = login.json()
if resp_json['status'] != 'ok': if resp_json['status'] != 'ok':
@@ -356,7 +343,8 @@ class InstaloaderContext:
raise BadCredentialsException("2FA error: {}".format(resp_json['message'])) raise BadCredentialsException("2FA error: {}".format(resp_json['message']))
else: else:
raise BadCredentialsException("2FA error: \"{}\" status.".format(resp_json['status'])) raise BadCredentialsException("2FA error: \"{}\" status.".format(resp_json['status']))
session.headers.update({'X-CSRFToken': login.cookies['csrftoken']}) if 'csrftoken' in login.cookies:
session.headers.update({'X-CSRFToken': login.cookies['csrftoken']})
self._session = session self._session = session
self.username = user self.username = user
self.two_factor_auth_pending = None self.two_factor_auth_pending = None
@@ -366,61 +354,33 @@ class InstaloaderContext:
if self.sleep: if self.sleep:
time.sleep(min(random.expovariate(0.6), 15.0)) time.sleep(min(random.expovariate(0.6), 15.0))
@staticmethod
def _response_error(resp: requests.Response) -> str:
extra_from_json: Optional[str] = None
with suppress(json.decoder.JSONDecodeError):
resp_json = resp.json()
if "status" in resp_json:
extra_from_json = (
f"\"{resp_json['status']}\" status, message \"{resp_json['message']}\""
if "message" in resp_json
else f"\"{resp_json['status']}\" status"
)
return (
f"{resp.status_code} {resp.reason}"
f"{f' - {extra_from_json}' if extra_from_json is not None else ''}"
f" when accessing {resp.url}"
)
def get_json(self, path: str, params: Dict[str, Any], host: str = 'www.instagram.com', def get_json(self, path: str, params: Dict[str, Any], host: str = 'www.instagram.com',
session: Optional[requests.Session] = None, _attempt=1, session: Optional[requests.Session] = None, _attempt=1,
response_headers: Optional[Dict[str, Any]] = None, response_headers: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
use_post: bool = False) -> Dict[str, Any]:
"""JSON request to Instagram. """JSON request to Instagram.
:param path: URL, relative to the given domain which defaults to www.instagram.com/ :param path: URL, relative to the given domain which defaults to www.instagram.com/
:param params: request parameters :param params: GET parameters
:param host: Domain part of the URL from where to download the requested JSON; defaults to www.instagram.com :param host: Domain part of the URL from where to download the requested JSON; defaults to www.instagram.com
:param session: Session to use, or None to use self.session :param session: Session to use, or None to use self.session
:param use_post: Use POST instead of GET to make the request
:return: Decoded response dictionary :return: Decoded response dictionary
:raises QueryReturnedBadRequestException: When the server responds with a 400. :raises QueryReturnedBadRequestException: When the server responds with a 400.
:raises QueryReturnedNotFoundException: When the server responds with a 404. :raises QueryReturnedNotFoundException: When the server responds with a 404.
:raises ConnectionException: When query repeatedly failed. :raises ConnectionException: When query repeatedly failed.
.. versionchanged:: 4.13
Added `use_post` parameter.
""" """
is_graphql_query = 'query_hash' in params and 'graphql/query' in path is_graphql_query = 'query_hash' in params and 'graphql/query' in path
is_doc_id_query = 'doc_id' in params and 'graphql/query' in path
is_iphone_query = host == 'i.instagram.com' is_iphone_query = host == 'i.instagram.com'
is_other_query = not is_graphql_query and not is_doc_id_query and host == "www.instagram.com" is_other_query = not is_graphql_query and host == "www.instagram.com"
sess = session if session else self._session sess = session if session else self._session
try: try:
self.do_sleep() self.do_sleep()
if is_graphql_query: if is_graphql_query:
self._rate_controller.wait_before_query(params['query_hash']) self._rate_controller.wait_before_query(params['query_hash'])
if is_doc_id_query:
self._rate_controller.wait_before_query(params['doc_id'])
if is_iphone_query: if is_iphone_query:
self._rate_controller.wait_before_query('iphone') self._rate_controller.wait_before_query('iphone')
if is_other_query: if is_other_query:
self._rate_controller.wait_before_query('other') self._rate_controller.wait_before_query('other')
if use_post: resp = sess.get('https://{0}/{1}'.format(host, path), params=params, allow_redirects=False)
resp = sess.post('https://{0}/{1}'.format(host, path), data=params, allow_redirects=False)
else:
resp = sess.get('https://{0}/{1}'.format(host, path), params=params, allow_redirects=False)
if resp.status_code in self.fatal_status_codes: if resp.status_code in self.fatal_status_codes:
redirect = " redirect to {}".format(resp.headers['location']) if 'location' in resp.headers else "" redirect = " redirect to {}".format(resp.headers['location']) if 'location' in resp.headers else ""
body = "" body = ""
@@ -435,7 +395,7 @@ class InstaloaderContext:
if (redirect_url.startswith('https://www.instagram.com/accounts/login') or if (redirect_url.startswith('https://www.instagram.com/accounts/login') or
redirect_url.startswith('https://i.instagram.com/accounts/login')): redirect_url.startswith('https://i.instagram.com/accounts/login')):
if not self.is_logged_in: if not self.is_logged_in:
raise LoginRequiredException("Redirected to login page. Use --login or --load-cookies.") raise LoginRequiredException("Redirected to login page. Use --login.")
raise AbortDownloadException("Redirected to login page. You've been logged out, please wait " + raise AbortDownloadException("Redirected to login page. You've been logged out, please wait " +
"some time, recreate the session and try again") "some time, recreate the session and try again")
if redirect_url.startswith('https://{}/'.format(host)): if redirect_url.startswith('https://{}/'.format(host)):
@@ -447,17 +407,31 @@ class InstaloaderContext:
response_headers.clear() response_headers.clear()
response_headers.update(resp.headers) response_headers.update(resp.headers)
if resp.status_code == 400: if resp.status_code == 400:
raise QueryReturnedBadRequestException(self._response_error(resp)) raise QueryReturnedBadRequestException("400 Bad Request")
if resp.status_code == 404: if resp.status_code == 404:
raise QueryReturnedNotFoundException(self._response_error(resp)) raise QueryReturnedNotFoundException("404 Not Found")
if resp.status_code == 429: if resp.status_code == 429:
raise TooManyRequestsException(self._response_error(resp)) raise TooManyRequestsException("429 Too Many Requests")
if resp.status_code != 200: if resp.status_code != 200:
raise ConnectionException(self._response_error(resp)) raise ConnectionException("HTTP error code {}.".format(resp.status_code))
is_html_query = not is_graphql_query and not "__a" in params and host == "www.instagram.com"
if is_html_query:
# Extract JSON from HTML response
match = re.search('(?<={"raw":").*?(?<!\\\\)(?=")', resp.text)
if match is None:
raise QueryReturnedNotFoundException("Could not find JSON data in html response.")
# Unescape escaped JSON string
unescaped_string = match.group(0).encode("utf-8").decode("unicode_escape")
resp_json = json.loads(unescaped_string)
return resp_json
else: else:
resp_json = resp.json() resp_json = resp.json()
if 'status' in resp_json and resp_json['status'] != "ok": if 'status' in resp_json and resp_json['status'] != "ok":
raise ConnectionException(self._response_error(resp)) if 'message' in resp_json:
raise ConnectionException("Returned \"{}\" status, message \"{}\".".format(resp_json['status'],
resp_json['message']))
else:
raise ConnectionException("Returned \"{}\" status.".format(resp_json['status']))
return resp_json return resp_json
except (ConnectionException, json.decoder.JSONDecodeError, requests.exceptions.RequestException) as err: except (ConnectionException, json.decoder.JSONDecodeError, requests.exceptions.RequestException) as err:
error_string = "JSON Query to {}: {}".format(path, err) error_string = "JSON Query to {}: {}".format(path, err)
@@ -471,8 +445,6 @@ class InstaloaderContext:
if isinstance(err, TooManyRequestsException): if isinstance(err, TooManyRequestsException):
if is_graphql_query: if is_graphql_query:
self._rate_controller.handle_429(params['query_hash']) self._rate_controller.handle_429(params['query_hash'])
if is_doc_id_query:
self._rate_controller.handle_429(params['doc_id'])
if is_iphone_query: if is_iphone_query:
self._rate_controller.handle_429('iphone') self._rate_controller.handle_429('iphone')
if is_other_query: if is_other_query:
@@ -484,17 +456,15 @@ class InstaloaderContext:
raise ConnectionException(error_string) from err raise ConnectionException(error_string) from err
def graphql_query(self, query_hash: str, variables: Dict[str, Any], def graphql_query(self, query_hash: str, variables: Dict[str, Any],
referer: Optional[str] = None) -> Dict[str, Any]: referer: Optional[str] = None, rhx_gis: Optional[str] = None) -> Dict[str, Any]:
""" """
Do a GraphQL Query. Do a GraphQL Query.
:param query_hash: Query identifying hash. :param query_hash: Query identifying 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.
:param rhx_gis: 'rhx_gis' variable as somewhere returned by Instagram, needed to 'sign' request
:return: The server's response dictionary. :return: The server's response dictionary.
.. versionchanged:: 4.13.1
Removed the `rhx_gis` parameter.
""" """
with copy_session(self._session, self.request_timeout) as tmpsession: with copy_session(self._session, self.request_timeout) as tmpsession:
tmpsession.headers.update(self._default_http_header(empty_session_only=True)) tmpsession.headers.update(self._default_http_header(empty_session_only=True))
@@ -508,6 +478,12 @@ class InstaloaderContext:
variables_json = json.dumps(variables, separators=(',', ':')) variables_json = json.dumps(variables, separators=(',', ':'))
if rhx_gis:
#self.log("rhx_gis {} query_hash {}".format(rhx_gis, query_hash))
values = "{}:{}".format(rhx_gis, variables_json)
x_instagram_gis = hashlib.md5(values.encode()).hexdigest()
tmpsession.headers['x-instagram-gis'] = x_instagram_gis
resp_json = self.get_json('graphql/query', resp_json = self.get_json('graphql/query',
params={'query_hash': query_hash, params={'query_hash': query_hash,
'variables': variables_json}, 'variables': variables_json},
@@ -516,44 +492,10 @@ class InstaloaderContext:
self.error("GraphQL response did not contain a \"status\" field.") self.error("GraphQL response did not contain a \"status\" field.")
return resp_json return resp_json
def doc_id_graphql_query(self, doc_id: str, variables: Dict[str, Any],
referer: Optional[str] = None) -> Dict[str, Any]:
"""
Do a doc_id-based GraphQL Query using method POST.
.. versionadded:: 4.13
:param doc_id: doc_id for the query.
:param variables: Variables for the Query.
:param referer: HTTP Referer, or None.
:return: The server's response dictionary.
"""
with copy_session(self._session, self.request_timeout) as tmpsession:
tmpsession.headers.update(self._default_http_header(empty_session_only=True))
del tmpsession.headers['Connection']
del tmpsession.headers['Content-Length']
tmpsession.headers['authority'] = 'www.instagram.com'
tmpsession.headers['scheme'] = 'https'
tmpsession.headers['accept'] = '*/*'
if referer is not None:
tmpsession.headers['referer'] = urllib.parse.quote(referer)
variables_json = json.dumps(variables, separators=(',', ':'))
resp_json = self.get_json('graphql/query',
params={'variables': variables_json,
'doc_id': doc_id,
'server_timestamps': 'true'},
session=tmpsession,
use_post=True)
if 'status' not in resp_json:
self.error("GraphQL response did not contain a \"status\" field.")
return resp_json
def graphql_node_list(self, query_hash: str, query_variables: Dict[str, Any], def graphql_node_list(self, query_hash: str, query_variables: Dict[str, Any],
query_referer: Optional[str], query_referer: Optional[str],
edge_extractor: Callable[[Dict[str, Any]], Dict[str, Any]], edge_extractor: Callable[[Dict[str, Any]], Dict[str, Any]],
_rhx_gis: Optional[str] = None, rhx_gis: Optional[str] = None,
first_data: Optional[Dict[str, Any]] = None) -> Iterator[Dict[str, Any]]: first_data: Optional[Dict[str, Any]] = None) -> Iterator[Dict[str, Any]]:
""" """
Retrieve a list of GraphQL nodes. Retrieve a list of GraphQL nodes.
@@ -565,7 +507,7 @@ class InstaloaderContext:
def _query(): def _query():
query_variables['first'] = self._graphql_page_length query_variables['first'] = self._graphql_page_length
try: try:
return edge_extractor(self.graphql_query(query_hash, query_variables, query_referer)) return edge_extractor(self.graphql_query(query_hash, query_variables, query_referer, rhx_gis))
except QueryReturnedBadRequestException: except QueryReturnedBadRequestException:
new_page_length = int(self._graphql_page_length / 2) new_page_length = int(self._graphql_page_length / 2)
if new_page_length >= 12: if new_page_length >= 12:
@@ -678,11 +620,11 @@ class InstaloaderContext:
else: else:
if resp.status_code == 403: if resp.status_code == 403:
# suspected invalid URL signature # suspected invalid URL signature
raise QueryReturnedForbiddenException(self._response_error(resp)) 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(self._response_error(resp)) raise QueryReturnedNotFoundException("404 when accessing {}.".format(url))
raise ConnectionException(self._response_error(resp)) raise ConnectionException("HTTP error code {}.".format(resp.status_code))
def get_and_write_raw(self, url: str, filename: str) -> None: def get_and_write_raw(self, url: str, filename: str) -> None:
"""Downloads and writes anonymously-requested raw data into a file. """Downloads and writes anonymously-requested raw data into a file.
@@ -708,11 +650,22 @@ class InstaloaderContext:
else: else:
if resp.status_code == 403: if resp.status_code == 403:
# suspected invalid URL signature # suspected invalid URL signature
raise QueryReturnedForbiddenException(self._response_error(resp)) 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(self._response_error(resp)) raise QueryReturnedNotFoundException("404 when accessing {}.".format(url))
raise ConnectionException(self._response_error(resp)) raise ConnectionException("HTTP error code {}.".format(resp.status_code))
@property
def root_rhx_gis(self) -> Optional[str]:
"""rhx_gis string returned in the / query."""
if self.is_logged_in:
# At the moment, rhx_gis seems to be required for anonymous requests only. By returning None when logged
# in, we can save the root_rhx_gis lookup query.
return None
if self._root_rhx_gis is None:
self._root_rhx_gis = self.get_json('', {}).get('rhx_gis', '')
return self._root_rhx_gis or None
class RateController: class RateController:
-9
View File
@@ -18,7 +18,6 @@ class LatestStamps:
POST_TIMESTAMP = 'post-timestamp' POST_TIMESTAMP = 'post-timestamp'
TAGGED_TIMESTAMP = 'tagged-timestamp' TAGGED_TIMESTAMP = 'tagged-timestamp'
IGTV_TIMESTAMP = 'igtv-timestamp' IGTV_TIMESTAMP = 'igtv-timestamp'
REELS_TIMESTAMP = 'reels-timestamp'
STORY_TIMESTAMP = 'story-timestamp' STORY_TIMESTAMP = 'story-timestamp'
ISO_FORMAT = '%Y-%m-%dT%H:%M:%S.%f%z' ISO_FORMAT = '%Y-%m-%dT%H:%M:%S.%f%z'
@@ -88,14 +87,6 @@ class LatestStamps:
"""Sets timestamp of last download of a profile's tagged posts.""" """Sets timestamp of last download of a profile's tagged posts."""
self._set_timestamp(profile_name, self.TAGGED_TIMESTAMP, timestamp) self._set_timestamp(profile_name, self.TAGGED_TIMESTAMP, timestamp)
def get_last_reels_timestamp(self, profile_name: str) -> datetime:
"""Returns timestamp of last download of a profile's reels posts."""
return self._get_timestamp(profile_name, self.REELS_TIMESTAMP)
def set_last_reels_timestamp(self, profile_name: str, timestamp: datetime):
"""Sets timestamp of last download of a profile's reels posts."""
self._set_timestamp(profile_name, self.REELS_TIMESTAMP, timestamp)
def get_last_igtv_timestamp(self, profile_name: str) -> datetime: def get_last_igtv_timestamp(self, profile_name: str) -> datetime:
"""Returns timestamp of last download of a profile's igtv posts.""" """Returns timestamp of last download of a profile's igtv posts."""
return self._get_timestamp(profile_name, self.IGTV_TIMESTAMP) return self._get_timestamp(profile_name, self.IGTV_TIMESTAMP)
+22 -47
View File
@@ -7,11 +7,11 @@ from datetime import datetime, timedelta
from lzma import LZMAError from lzma import LZMAError
from typing import Any, Callable, Dict, Iterable, Iterator, NamedTuple, Optional, Tuple, TypeVar from typing import Any, Callable, Dict, Iterable, Iterator, NamedTuple, Optional, Tuple, TypeVar
from .exceptions import AbortDownloadException, InvalidArgumentException from .exceptions import AbortDownloadException, InvalidArgumentException, QueryReturnedBadRequestException
from .instaloadercontext import InstaloaderContext from .instaloadercontext import InstaloaderContext
class FrozenNodeIterator(NamedTuple): class FrozenNodeIterator(NamedTuple):
query_hash: Optional[str] query_hash: str
query_variables: Dict query_variables: Dict
query_referer: Optional[str] query_referer: Optional[str]
context_username: Optional[str] context_username: Optional[str]
@@ -19,7 +19,6 @@ class FrozenNodeIterator(NamedTuple):
best_before: Optional[float] best_before: Optional[float]
remaining_data: Optional[Dict] remaining_data: Optional[Dict]
first_node: Optional[Dict] first_node: Optional[Dict]
doc_id: Optional[str]
FrozenNodeIterator.query_hash.__doc__ = """The GraphQL ``query_hash`` parameter.""" FrozenNodeIterator.query_hash.__doc__ = """The GraphQL ``query_hash`` parameter."""
FrozenNodeIterator.query_variables.__doc__ = """The GraphQL ``query_variables`` parameter.""" FrozenNodeIterator.query_variables.__doc__ = """The GraphQL ``query_variables`` parameter."""
FrozenNodeIterator.query_referer.__doc__ = """The HTTP referer used for the GraphQL query.""" FrozenNodeIterator.query_referer.__doc__ = """The HTTP referer used for the GraphQL query."""
@@ -29,7 +28,6 @@ FrozenNodeIterator.best_before.__doc__ = """Date when parts of the stored nodes
FrozenNodeIterator.remaining_data.__doc__ = \ FrozenNodeIterator.remaining_data.__doc__ = \
"""The already-retrieved, yet-unprocessed ``edges`` and the ``page_info`` at time of freezing.""" """The already-retrieved, yet-unprocessed ``edges`` and the ``page_info`` at time of freezing."""
FrozenNodeIterator.first_node.__doc__ = """Node data of the first item, if an item has been produced.""" FrozenNodeIterator.first_node.__doc__ = """Node data of the first item, if an item has been produced."""
FrozenNodeIterator.doc_id.__doc__ = """The GraphQL ``doc_id`` parameter."""
T = TypeVar('T') T = TypeVar('T')
@@ -66,27 +64,22 @@ class NodeIterator(Iterator[T]):
NodeIterators are matching if and only if they have the same magic. NodeIterators are matching if and only if they have the same magic.
See also :func:`resumable_iteration` for a high-level context manager that handles a resumable iteration. See also :func:`resumable_iteration` for a high-level context manager that handles a resumable iteration.
.. versionchanged: 4.13
Included support for `doc_id`-based queries (using POST method).
""" """
_graphql_page_length = 12 _graphql_page_length = 50
_shelf_life = timedelta(days=29) _shelf_life = timedelta(days=29)
def __init__(self, def __init__(self,
context: InstaloaderContext, context: InstaloaderContext,
query_hash: Optional[str], query_hash: str,
edge_extractor: Callable[[Dict[str, Any]], Dict[str, Any]], edge_extractor: Callable[[Dict[str, Any]], Dict[str, Any]],
node_wrapper: Callable[[Dict], T], node_wrapper: Callable[[Dict], T],
query_variables: Optional[Dict[str, Any]] = None, query_variables: Optional[Dict[str, Any]] = None,
query_referer: Optional[str] = None, query_referer: Optional[str] = None,
first_data: Optional[Dict[str, Any]] = None, first_data: Optional[Dict[str, Any]] = None,
is_first: Optional[Callable[[T, Optional[T]], bool]] = None, is_first: Optional[Callable[[T, Optional[T]], bool]] = None):
doc_id: Optional[str] = None):
self._context = context self._context = context
self._query_hash = query_hash self._query_hash = query_hash
self._doc_id = doc_id
self._edge_extractor = edge_extractor self._edge_extractor = edge_extractor
self._node_wrapper = node_wrapper self._node_wrapper = node_wrapper
self._query_variables = query_variables if query_variables is not None else {} self._query_variables = query_variables if query_variables is not None else {}
@@ -102,38 +95,26 @@ class NodeIterator(Iterator[T]):
self._is_first = is_first self._is_first = is_first
def _query(self, after: Optional[str] = None) -> Dict: def _query(self, after: Optional[str] = None) -> Dict:
if self._doc_id is not None:
return self._query_doc_id(self._doc_id, after)
else:
assert self._query_hash is not None
return self._query_query_hash(self._query_hash, after)
def _query_doc_id(self, doc_id: str, after: Optional[str] = None) -> Dict:
pagination_variables: Dict[str, Any] = {'__relay_internal__pv__PolarisFeedShareMenurelayprovider': False}
if after is not None:
pagination_variables['after'] = after
pagination_variables['before'] = None
pagination_variables['first'] = 12
pagination_variables['last'] = None
data = self._edge_extractor(
self._context.doc_id_graphql_query(
doc_id, {**self._query_variables, **pagination_variables}, self._query_referer
)
)
self._best_before = datetime.now() + NodeIterator._shelf_life
return data
def _query_query_hash(self, query_hash: str, after: Optional[str] = None) -> Dict:
pagination_variables: Dict[str, Any] = {'first': NodeIterator._graphql_page_length} pagination_variables: Dict[str, Any] = {'first': NodeIterator._graphql_page_length}
if after is not None: if after is not None:
pagination_variables['after'] = after pagination_variables['after'] = after
data = self._edge_extractor( try:
self._context.graphql_query( data = self._edge_extractor(
query_hash, {**self._query_variables, **pagination_variables}, self._query_referer self._context.graphql_query(
self._query_hash, {**self._query_variables, **pagination_variables}, self._query_referer
)
) )
) self._best_before = datetime.now() + NodeIterator._shelf_life
self._best_before = datetime.now() + NodeIterator._shelf_life return data
return data except QueryReturnedBadRequestException:
new_page_length = int(NodeIterator._graphql_page_length / 2)
if new_page_length >= 12:
NodeIterator._graphql_page_length = new_page_length
self._context.error("HTTP Error 400 (Bad Request) on GraphQL Query. Retrying with shorter page length.",
repeat_at_end=False)
return self._query(after)
else:
raise
def __iter__(self): def __iter__(self):
return self return self
@@ -203,10 +184,6 @@ class NodeIterator(Iterator[T]):
""" """
return self._node_wrapper(self._first_node) if self._first_node is not None else None return self._node_wrapper(self._first_node) if self._first_node is not None else None
@staticmethod
def page_length() -> int:
return NodeIterator._graphql_page_length
def freeze(self) -> FrozenNodeIterator: def freeze(self) -> FrozenNodeIterator:
"""Freeze the iterator for later resuming.""" """Freeze the iterator for later resuming."""
remaining_data = None remaining_data = None
@@ -222,7 +199,6 @@ class NodeIterator(Iterator[T]):
best_before=self._best_before.timestamp() if self._best_before else None, best_before=self._best_before.timestamp() if self._best_before else None,
remaining_data=remaining_data, remaining_data=remaining_data,
first_node=self._first_node, first_node=self._first_node,
doc_id=self._doc_id,
) )
def thaw(self, frozen: FrozenNodeIterator) -> None: def thaw(self, frozen: FrozenNodeIterator) -> None:
@@ -240,8 +216,7 @@ class NodeIterator(Iterator[T]):
if (self._query_hash != frozen.query_hash or if (self._query_hash != frozen.query_hash or
self._query_variables != frozen.query_variables or self._query_variables != frozen.query_variables or
self._query_referer != frozen.query_referer or self._query_referer != frozen.query_referer or
self._context.username != frozen.context_username or self._context.username != frozen.context_username):
self._doc_id != frozen.doc_id):
raise InvalidArgumentException("Mismatching resume information.") raise InvalidArgumentException("Mismatching resume information.")
if not frozen.best_before: if not frozen.best_before:
raise InvalidArgumentException("\"best before\" date missing.") raise InvalidArgumentException("\"best before\" date missing.")
+48 -242
View File
@@ -43,91 +43,19 @@ PostCommentAnswer.owner.__doc__ = "Owner :class:`Profile` of the comment."
PostCommentAnswer.likes_count.__doc__ = "Number of likes on comment." PostCommentAnswer.likes_count.__doc__ = "Number of likes on comment."
class PostComment: class PostComment(NamedTuple):
def __init__(self, context: 'InstaloaderContext', node: Dict[str, Any], id: int
answers: Iterator['PostCommentAnswer'], post: 'Post'): created_at_utc: datetime
self._context = context text: str
self._node = node owner: 'Profile'
self._answers = answers likes_count: int
self._post = post answers: Iterator[PostCommentAnswer]
@classmethod
def from_iphone_struct(
cls,
context: "InstaloaderContext",
media: Dict[str, Any],
answers: Iterator["PostCommentAnswer"],
post: "Post",
):
return cls(
context=context,
node={
"id": int(media["pk"]),
"created_at": media["created_at"],
"text": media["text"],
"edge_liked_by": {
"count": media["comment_like_count"],
},
"iphone_struct": media,
},
answers=answers,
post=post,
)
@property for field in PostCommentAnswer._fields:
def id(self) -> int: getattr(PostComment, field).__doc__ = getattr(PostCommentAnswer, field).__doc__ # pylint: disable=no-member
""" ID number of comment. """ PostComment.answers.__doc__ = r"Iterator which yields all :class:`PostCommentAnswer`\ s for the comment."
return self._node['id']
@property
def created_at_utc(self) -> datetime:
""" :class:`~datetime.datetime` when comment was created (UTC). """
return datetime.utcfromtimestamp(self._node['created_at'])
@property
def text(self):
""" Comment text. """
return self._node['text']
@property
def owner(self) -> "Profile":
""" Owner :class:`Profile` of the comment. """
if "iphone_struct" in self._node:
return Profile.from_iphone_struct(
self._context, self._node["iphone_struct"]["user"]
)
return Profile(self._context, self._node["owner"])
@property
def likes_count(self):
""" Number of likes on comment. """
return self._node.get('edge_liked_by', {}).get('count', 0)
@property
def answers(self) -> Iterator['PostCommentAnswer']:
""" Iterator which yields all :class:`PostCommentAnswer` for the comment. """
return self._answers
@property
def likes(self) -> Iterable['Profile']:
"""
Iterate over all likes of a comment. A :class:`Profile` instance of each like is yielded.
.. versionadded:: 4.11
"""
if self.likes_count != 0:
return NodeIterator(
self._context,
'5f0b1f6281e72053cbc07909c8d154ae',
lambda d: d['data']['comment']['edge_liked_by'],
lambda n: Profile(self._context, n),
{'comment_id': self.id},
'https://www.instagram.com/p/{0}/'.format(self._post.shortcode),
)
return []
def __repr__(self):
return f'<PostComment {self.id} of {self._post.shortcode}>'
class PostLocation(NamedTuple): class PostLocation(NamedTuple):
id: int id: int
@@ -232,32 +160,21 @@ class Post:
"title": media.get("title"), "title": media.get("title"),
"viewer_has_liked": media["has_liked"], "viewer_has_liked": media["has_liked"],
"edge_media_preview_like": {"count": media["like_count"]}, "edge_media_preview_like": {"count": media["like_count"]},
"accessibility_caption": media.get("accessibility_caption"),
"comments": media.get("comment_count"),
"iphone_struct": media, "iphone_struct": media,
} }
with suppress(KeyError): with suppress(KeyError):
fake_node["display_url"] = media['image_versions2']['candidates'][0]['url'] fake_node["display_url"] = media['image_versions2']['candidates'][0]['url']
with suppress(KeyError, TypeError): with suppress(KeyError):
fake_node["video_url"] = media['video_versions'][-1]['url'] fake_node["video_url"] = media['video_versions'][-1]['url']
fake_node["video_duration"] = media["video_duration"] fake_node["video_duration"] = media["video_duration"]
fake_node["video_view_count"] = media["view_count"] fake_node["video_view_count"] = media["view_count"]
with suppress(KeyError, TypeError): with suppress(KeyError):
fake_node["edge_sidecar_to_children"] = {"edges": [{"node": fake_node["edge_sidecar_to_children"] = {"edges": [{"node": {
Post._convert_iphone_carousel(node, media_types)} "display_url": node['image_versions2']['candidates'][0]['url'],
for node in media["carousel_media"]]} "is_video": media_types[node["media_type"]] == "GraphVideo",
}} for node in media["carousel_media"]]}
return cls(context, fake_node, Profile.from_iphone_struct(context, media["user"]) if "user" in media else None) return cls(context, fake_node, Profile.from_iphone_struct(context, media["user"]) if "user" in media else None)
@staticmethod
def _convert_iphone_carousel(iphone_node: Dict[str, Any], media_types: Dict[int, str]) -> Dict[str, Any]:
fake_node = {
"display_url": iphone_node["image_versions2"]["candidates"][0]["url"],
"is_video": media_types[iphone_node["media_type"]] == "GraphVideo",
}
if "video_versions" in iphone_node and iphone_node["video_versions"] is not None:
fake_node["video_url"] = iphone_node["video_versions"][0]["url"]
return fake_node
@staticmethod @staticmethod
def shortcode_to_mediaid(code: str) -> int: def shortcode_to_mediaid(code: str) -> int:
if len(code) > 11: if len(code) > 11:
@@ -319,23 +236,13 @@ class Post:
def _obtain_metadata(self): def _obtain_metadata(self):
if not self._full_metadata_dict: if not self._full_metadata_dict:
pic_json = self._context.doc_id_graphql_query( pic_json = self._context.graphql_query(
"8845758582119845", {"shortcode": self.shortcode} '2b0673e0dc4580674a88d426fe00ea90',
)["data"]["xdt_shortcode_media"] {'shortcode': self.shortcode}
if pic_json is None: )
self._full_metadata_dict = pic_json['data']['shortcode_media']
if self._full_metadata_dict is None:
raise BadResponseException("Fetching Post metadata failed.") raise BadResponseException("Fetching Post metadata failed.")
try:
xdt_types = {
"XDTGraphImage": "GraphImage",
"XDTGraphVideo": "GraphVideo",
"XDTGraphSidecar": "GraphSidecar",
}
pic_json["__typename"] = xdt_types[pic_json["__typename"]]
except KeyError as exc:
raise BadResponseException(
f"Unknown __typename in metadata: {pic_json['__typename']}."
) from exc
self._full_metadata_dict = pic_json
if self.shortcode != self._full_metadata_dict['shortcode']: if self.shortcode != self._full_metadata_dict['shortcode']:
self._node.update(self._full_metadata_dict) self._node.update(self._full_metadata_dict)
raise PostChangedException raise PostChangedException
@@ -351,7 +258,7 @@ class Post:
if not self._context.iphone_support: if not self._context.iphone_support:
raise IPhoneSupportDisabledException("iPhone support is disabled.") raise IPhoneSupportDisabledException("iPhone support is disabled.")
if not self._context.is_logged_in: if not self._context.is_logged_in:
raise LoginRequiredException("Login required to access iPhone media info endpoint.") raise LoginRequiredException("--login required to access iPhone media info endpoint.")
if not self._iphone_struct_: if not self._iphone_struct_:
data = self._context.get_iphone_json(path='api/v1/media/{}/info/'.format(self.mediaid), params={}) data = self._context.get_iphone_json(path='api/v1/media/{}/info/'.format(self.mediaid), params={})
self._iphone_struct_ = data['items'][0] self._iphone_struct_ = data['items'][0]
@@ -638,77 +545,16 @@ class Post:
except KeyError: except KeyError:
return self._field('edge_media_to_comment', 'count') return self._field('edge_media_to_comment', 'count')
def _get_comments_via_iphone_endpoint(self) -> Iterable[PostComment]:
"""
Iterate over all comments of the post via an iPhone endpoint.
.. versionadded:: 4.10.3
fallback for :issue:`2125`.
"""
def _query(min_id=None):
pagination_params = {"min_id": min_id} if min_id is not None else {}
return self._context.get_iphone_json(
f"api/v1/media/{self.mediaid}/comments/",
{
"can_support_threading": "true",
"permalink_enabled": "false",
**pagination_params,
},
)
def _answers(comment_node):
def _answer(child_comment):
return PostCommentAnswer(
id=int(child_comment["pk"]),
created_at_utc=datetime.utcfromtimestamp(child_comment["created_at"]),
text=child_comment["text"],
owner=Profile.from_iphone_struct(self._context, child_comment["user"]),
likes_count=child_comment["comment_like_count"],
)
child_comment_count = comment_node["child_comment_count"]
if child_comment_count == 0:
return
preview_child_comments = comment_node["preview_child_comments"]
if child_comment_count == len(preview_child_comments):
yield from (
_answer(child_comment) for child_comment in preview_child_comments
)
return
pk = comment_node["pk"]
answers_json = self._context.get_iphone_json(
f"api/v1/media/{self.mediaid}/comments/{pk}/child_comments/",
{"max_id": ""},
)
yield from (
_answer(child_comment) for child_comment in answers_json["child_comments"]
)
def _paginated_comments(comments_json):
for comment_node in comments_json.get("comments", []):
yield PostComment.from_iphone_struct(
self._context, comment_node, _answers(comment_node), self
)
next_min_id = comments_json.get("next_min_id")
if next_min_id:
yield from _paginated_comments(_query(next_min_id))
return _paginated_comments(_query())
def get_comments(self) -> Iterable[PostComment]: def get_comments(self) -> Iterable[PostComment]:
"""Iterate over all comments of the post. r"""Iterate over all comments of the post.
Each comment is represented by a PostComment NamedTuple with fields text (string), created_at (datetime), Each comment is represented by a PostComment NamedTuple with fields text (string), created_at (datetime),
id (int), owner (:class:`Profile`) and answers (:class:`~typing.Iterator` [:class:`PostCommentAnswer`]) id (int), owner (:class:`Profile`) and answers (:class:`~typing.Iterator`\ [:class:`PostCommentAnswer`])
if available. if available.
.. versionchanged:: 4.7 .. versionchanged:: 4.7
Change return type to ``Iterable``. Change return type to ``Iterable``.
""" """
if not self._context.is_logged_in:
raise LoginRequiredException("Login required to access comments of a post.")
def _postcommentanswer(node): def _postcommentanswer(node):
return PostCommentAnswer(id=int(node['id']), return PostCommentAnswer(id=int(node['id']),
created_at_utc=datetime.utcfromtimestamp(node['created_at']), created_at_utc=datetime.utcfromtimestamp(node['created_at']),
@@ -738,28 +584,18 @@ class Post:
) )
def _postcomment(node): def _postcomment(node):
return PostComment(context=self._context, node=node, return PostComment(*_postcommentanswer(node),
answers=_postcommentanswers(node), post=self) answers=_postcommentanswers(node))
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 []
try: comment_edges = self._field('edge_media_to_comment', 'edges')
comment_edges = self._field("edge_media_to_parent_comment", "edges")
except KeyError:
comment_edges = self._field("edge_media_to_comment", "edges")
answers_count = sum(edge['node'].get('edge_threaded_comments', {}).get('count', 0) for edge in comment_edges) answers_count = sum(edge['node'].get('edge_threaded_comments', {}).get('count', 0) for edge in comment_edges)
if self.comments == len(comment_edges) + answers_count: if self.comments == len(comment_edges) + answers_count:
# If the Post's metadata already contains all parent comments, don't do GraphQL requests to obtain them # If the Post's metadata already contains all parent comments, don't do GraphQL requests to obtain them
return [_postcomment(comment['node']) for comment in comment_edges] return [_postcomment(comment['node']) for comment in comment_edges]
if self.comments > NodeIterator.page_length():
# comments pagination via our graphql query does not work reliably anymore (issue #2125), fallback to an
# iphone endpoint if needed.
return self._get_comments_via_iphone_endpoint()
return NodeIterator( return NodeIterator(
self._context, self._context,
'97b41c52301f77ce508f55e66d17620e', '97b41c52301f77ce508f55e66d17620e',
@@ -777,7 +613,7 @@ class Post:
Require being logged in (as required by Instagram). Require being logged in (as required by Instagram).
""" """
if not self._context.is_logged_in: if not self._context.is_logged_in:
raise LoginRequiredException("Login required to access likes of a post.") raise LoginRequiredException("--login required to access likes of a post.")
if self.likes == 0: if self.likes == 0:
# Avoid doing additional requests if there are no comments # Avoid doing additional requests if there are no comments
return return
@@ -842,11 +678,7 @@ class Post:
@property @property
def is_pinned(self) -> bool: def is_pinned(self) -> bool:
""" """True if this Post has been pinned by at least one user.
.. deprecated: 4.10.3
This information is not returned by IG anymore
Used to return True if this Post has been pinned by at least one user, now likely returns always false.
.. versionadded: 4.9.2""" .. versionadded: 4.9.2"""
return 'pinned_for_users' in self._node and bool(self._node['pinned_for_users']) return 'pinned_for_users' in self._node and bool(self._node['pinned_for_users'])
@@ -922,7 +754,8 @@ class Profile:
'include_reel': True, 'include_reel': True,
'include_suggested_users': False, 'include_suggested_users': False,
'include_logged_out_extras': False, 'include_logged_out_extras': False,
'include_highlight_reels': False})['data']['user'] 'include_highlight_reels': False},
rhx_gis=context.root_rhx_gis)['data']['user']
if data: if data:
profile = cls(context, data['reel']['owner']) profile = cls(context, data['reel']['owner'])
else: else:
@@ -953,7 +786,7 @@ class Profile:
.. versionadded:: 4.5.2""" .. versionadded:: 4.5.2"""
if not context.is_logged_in: if not context.is_logged_in:
raise LoginRequiredException("Login required to access own profile.") raise LoginRequiredException("--login required to access own profile.")
return cls(context, context.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {})["data"]["user"]) return cls(context, context.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {})["data"]["user"])
def _asdict(self): def _asdict(self):
@@ -1007,7 +840,7 @@ class Profile:
if not self._context.iphone_support: if not self._context.iphone_support:
raise IPhoneSupportDisabledException("iPhone support is disabled.") raise IPhoneSupportDisabledException("iPhone support is disabled.")
if not self._context.is_logged_in: if not self._context.is_logged_in:
raise LoginRequiredException("Login required to access iPhone profile info endpoint.") raise LoginRequiredException("--login required to access iPhone profile info endpoint.")
if not self._iphone_struct_: if not self._iphone_struct_:
data = self._context.get_iphone_json(path='api/v1/users/{}/info/'.format(self.userid), params={}) data = self._context.get_iphone_json(path='api/v1/users/{}/info/'.format(self.userid), params={})
self._iphone_struct_ = data['user'] self._iphone_struct_ = data['user']
@@ -1198,17 +1031,14 @@ class Profile:
:rtype: NodeIterator[Post]""" :rtype: NodeIterator[Post]"""
self._obtain_metadata() self._obtain_metadata()
return NodeIterator( return NodeIterator(
context = self._context, self._context,
edge_extractor = lambda d: d['data']['xdt_api__v1__feed__user_timeline_graphql_connection'], '003056d32c2554def87228bc3fd9668a',
node_wrapper = lambda n: Post.from_iphone_struct(self._context, n), lambda d: d['data']['user']['edge_owner_to_timeline_media'],
query_variables = {'data': { lambda n: Post(self._context, n, self),
'count': 12, 'include_relationship_info': True, {'id': self.userid},
'latest_besties_reel_media': True, 'latest_reel_media': True}, 'https://www.instagram.com/{0}/'.format(self.username),
'username': self.username}, self._metadata('edge_owner_to_timeline_media'),
query_referer = 'https://www.instagram.com/{0}/'.format(self.username), Profile._make_is_newest_checker()
is_first = Profile._make_is_newest_checker(),
doc_id = '7898261790222653',
query_hash = None,
) )
def get_saved_posts(self) -> NodeIterator[Post]: def get_saved_posts(self) -> NodeIterator[Post]:
@@ -1217,7 +1047,7 @@ class Profile:
:rtype: NodeIterator[Post]""" :rtype: NodeIterator[Post]"""
if self.username != self._context.username: if self.username != self._context.username:
raise LoginRequiredException(f"Login as {self.username} required to get that profile's saved posts.") raise LoginRequiredException("--login={} required to get that profile's saved posts.".format(self.username))
return NodeIterator( return NodeIterator(
self._context, self._context,
@@ -1245,30 +1075,6 @@ class Profile:
is_first=Profile._make_is_newest_checker() is_first=Profile._make_is_newest_checker()
) )
def get_reels(self) -> NodeIterator[Post]:
"""Retrieve all reels from a profile.
:rtype: NodeIterator[Post]
.. versionadded:: 4.14.0
"""
self._obtain_metadata()
return NodeIterator(
context = self._context,
edge_extractor = lambda d: d['data']['xdt_api__v1__clips__user__connection_v2'],
# Reels post info is incomplete relative to regular posts so we create a Post from the shortcode
# and fetch the additional metadata with an additional API request per Reel
node_wrapper = lambda n: Post.from_shortcode(context=self._context, shortcode=n["media"]["code"]),
query_variables = {'data': {
'page_size': 12, 'include_feed_video': True, "target_user_id": str(self.userid)}},
query_referer = 'https://www.instagram.com/{0}/'.format(self.username),
is_first = Profile._make_is_newest_checker(),
# fb_api_req_friendly_name=PolarisProfileReelsTabContentQuery_connection
doc_id = '7845543455542541',
query_hash = None,
)
def get_igtv_posts(self) -> NodeIterator[Post]: def get_igtv_posts(self) -> NodeIterator[Post]:
"""Retrieve all IGTV posts. """Retrieve all IGTV posts.
@@ -1301,7 +1107,7 @@ class Profile:
.. versionadded:: 4.10 .. versionadded:: 4.10
""" """
if not self._context.is_logged_in: if not self._context.is_logged_in:
raise LoginRequiredException("Login required to get a profile's followers.") raise LoginRequiredException("--login required to get a profile's followers.")
self._obtain_metadata() self._obtain_metadata()
return NodeIterator( return NodeIterator(
self._context, self._context,
@@ -1320,7 +1126,7 @@ class Profile:
:rtype: NodeIterator[Profile] :rtype: NodeIterator[Profile]
""" """
if not self._context.is_logged_in: if not self._context.is_logged_in:
raise LoginRequiredException("Login required to get a profile's followers.") raise LoginRequiredException("--login required to get a profile's followers.")
self._obtain_metadata() self._obtain_metadata()
return NodeIterator( return NodeIterator(
self._context, self._context,
@@ -1339,7 +1145,7 @@ class Profile:
:rtype: NodeIterator[Profile] :rtype: NodeIterator[Profile]
""" """
if not self._context.is_logged_in: if not self._context.is_logged_in:
raise LoginRequiredException("Login required to get a profile's followees.") raise LoginRequiredException("--login required to get a profile's followees.")
self._obtain_metadata() self._obtain_metadata()
return NodeIterator( return NodeIterator(
self._context, self._context,
@@ -1358,7 +1164,7 @@ class Profile:
.. versionadded:: 4.4 .. versionadded:: 4.4
""" """
if not self._context.is_logged_in: if not self._context.is_logged_in:
raise LoginRequiredException("Login required to get a profile's similar accounts.") raise LoginRequiredException("--login required to get a profile's similar accounts.")
self._obtain_metadata() self._obtain_metadata()
yield from (Profile(self._context, edge["node"]) for edge in yield from (Profile(self._context, edge["node"]) for edge in
self._context.graphql_query("ad99dd9d3646cc3c0dda65debcd266a7", self._context.graphql_query("ad99dd9d3646cc3c0dda65debcd266a7",
@@ -1437,7 +1243,7 @@ class StoryItem:
if not self._context.iphone_support: if not self._context.iphone_support:
raise IPhoneSupportDisabledException("iPhone support is disabled.") raise IPhoneSupportDisabledException("iPhone support is disabled.")
if not self._context.is_logged_in: if not self._context.is_logged_in:
raise LoginRequiredException("Login required to access iPhone media info endpoint.") raise LoginRequiredException("--login required to access iPhone media info endpoint.")
if not self._iphone_struct_: if not self._iphone_struct_:
data = self._context.get_iphone_json( data = self._context.get_iphone_json(
path='api/v1/feed/reels_media/?reel_ids={}'.format(self.owner_id), params={} path='api/v1/feed/reels_media/?reel_ids={}'.format(self.owner_id), params={}
+5 -10
View File
@@ -18,13 +18,10 @@ def get_version():
raise SystemExit("Could not find version string.") raise SystemExit("Could not find version string.")
if sys.version_info < (3, 9): if sys.version_info < (3, 8):
sys.exit('Instaloader requires Python >= 3.9.') sys.exit('Instaloader requires Python >= 3.8.')
requirements = ['requests>=2.25'] requirements = ['requests>=2.4']
optional_requirements = {
'browser_cookie3': ['browser_cookie3>=0.19.1'],
}
keywords = (['instagram', 'instagram-scraper', 'instagram-client', 'instagram-feed', 'downloader', 'videos', 'photos', keywords = (['instagram', 'instagram-scraper', 'instagram-client', 'instagram-feed', 'downloader', 'videos', 'photos',
'pictures', 'instagram-user-photos', 'instagram-photos', 'instagram-metadata', 'instagram-downloader', 'pictures', 'instagram-user-photos', 'instagram-photos', 'instagram-metadata', 'instagram-downloader',
@@ -46,8 +43,7 @@ setup(
'from Instagram.', 'from Instagram.',
long_description=open(os.path.join(SRC, 'README.rst')).read(), long_description=open(os.path.join(SRC, 'README.rst')).read(),
install_requires=requirements, install_requires=requirements,
python_requires='>=3.9', python_requires='>=3.8',
extras_require=optional_requirements,
entry_points={'console_scripts': ['instaloader=instaloader.__main__:main']}, entry_points={'console_scripts': ['instaloader=instaloader.__main__:main']},
zip_safe=False, zip_safe=False,
keywords=keywords, keywords=keywords,
@@ -58,11 +54,10 @@ setup(
'Intended Audience :: Developers', 'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License', 'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent', 'Operating System :: OS Independent',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Programming Language :: Python :: 3.13',
'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3 :: Only',
'Topic :: Internet', 'Topic :: Internet',
'Topic :: Multimedia :: Graphics' 'Topic :: Multimedia :: Graphics'