mirror of
https://github.com/instaloader/instaloader.git
synced 2026-10-19 23:22:16 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52f258e89b | ||
|
|
e9888e72fd | ||
|
|
c72b2a8fc2 | ||
|
|
e2cec0a0bf | ||
|
|
08eaa7d474 | ||
|
|
f70355e803 | ||
|
|
bba7d5131c | ||
|
|
f6731566cd | ||
|
|
cd13211603 | ||
|
|
8635a576f0 | ||
|
|
8f594a3a30 | ||
|
|
0f11ef1bdd | ||
|
|
a4bb353421 | ||
|
|
dfdd744804 | ||
|
|
9fb112faa8 | ||
|
|
f85e49d521 | ||
|
|
6c4da39372 | ||
|
|
a2d756b177 | ||
|
|
e15d67c065 | ||
|
|
640066dd60 | ||
|
|
672b0b1840 | ||
|
|
828fa71ede | ||
|
|
4fd57f5598 | ||
|
|
c5b686a213 | ||
|
|
43c521afa9 | ||
|
|
3955540080 | ||
|
|
3fe6b1e139 | ||
|
|
a2ce906fd0 | ||
|
|
850cd39be4 | ||
|
|
227efa7b68 | ||
|
|
d7f7cad4a2 | ||
|
|
2f69ea62a1 | ||
|
|
0f258b6118 | ||
|
|
b31f279527 | ||
|
|
ed55ce140d | ||
|
|
dcc7ab89c1 | ||
|
|
610756d342 | ||
|
|
097bf7fecc | ||
|
|
3dcb0ff542 | ||
|
|
0be1129173 | ||
|
|
ace0e944ec | ||
|
|
3530cdc55c | ||
|
|
ed3fce45da | ||
|
|
a045168c79 | ||
|
|
4c02a186d3 | ||
|
|
e9648bc551 | ||
|
|
6d90c47d9a | ||
|
|
1c577716dd | ||
|
|
6824d7b75c | ||
|
|
6ad03d3575 | ||
|
|
03ac067e71 | ||
|
|
e70faf506d | ||
|
|
e11b88d44b | ||
|
|
cc2986cff3 | ||
|
|
c7372e36cc | ||
|
|
1c2e226630 | ||
|
|
74f7970b50 | ||
|
|
f4ecfea64c | ||
|
|
0586a1fec3 | ||
|
|
c53625028d | ||
|
|
4ad4584b0d | ||
|
|
886530d661 | ||
|
|
574cadbb82 | ||
|
|
6d3c73eec3 | ||
|
|
438669b954 | ||
|
|
a7a05c200c | ||
|
|
f53bdf9ef8 | ||
|
|
13612c606e | ||
|
|
b57bbe2af0 | ||
|
|
3a6ba6485e | ||
|
|
177a299ced | ||
|
|
4a1fe035af | ||
|
|
db86f81be7 | ||
|
|
020d412342 | ||
|
|
6f57197afb | ||
|
|
57393f619d | ||
|
|
d5dd8cec00 | ||
|
|
3652e009d9 | ||
|
|
ecde92d05e | ||
|
|
a8d3851956 | ||
|
|
c71873231d | ||
|
|
3823b1e593 | ||
|
|
6c779e8d4b | ||
|
|
c24015a93d | ||
|
|
2c56e6994a | ||
|
|
6791e9a2f8 | ||
|
|
64652f3994 | ||
|
|
4f5b223b60 | ||
|
|
2a3711687f | ||
|
|
612842f255 | ||
|
|
fe9cd653e7 | ||
|
|
82e7b19d6d | ||
|
|
5d7c36399f | ||
|
|
f0e66ef465 | ||
|
|
f71ea562af | ||
|
|
bc40b82f94 | ||
|
|
c817d1901a | ||
|
|
fbe05a1add | ||
|
|
fa62025ea3 |
@@ -13,7 +13,7 @@ Steps to reproduce the behavior:
|
|||||||
(e.g. Instaloader command line)
|
(e.g. Instaloader command line)
|
||||||
|
|
||||||
**Expected behavior**
|
**Expected behavior**
|
||||||
A clear and concise description of what you expected to happen (if not obvious).
|
A clear and concise description of what you expected to happen (even if it seems obvious).
|
||||||
|
|
||||||
**Error messages and tracebacks**
|
**Error messages and tracebacks**
|
||||||
If applicable, add error messages and tracebacks to help explain your problem.
|
If applicable, add error messages and tracebacks to help explain your problem.
|
||||||
@@ -24,4 +24,9 @@ If applicable, add error messages and tracebacks to help explain your problem.
|
|||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
|
|
||||||
<!-- please also see https://instaloader.github.io/contributing.html for how to report a bug -->
|
<!--
|
||||||
|
Also see https://instaloader.github.io/contributing.html for how to report a bug.
|
||||||
|
|
||||||
|
Please understand that a bug issue that lacks the required information cannot be
|
||||||
|
processed.
|
||||||
|
-->
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ A clear and concise description of what you want to happen.
|
|||||||
**Describe alternatives you've considered**
|
**Describe alternatives you've considered**
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
**If the feature request is accepted, would you be willing to submit a PR?**
|
**If the feature request is accepted, would you be willing to submit a pull request?**
|
||||||
Yes / No
|
Yes / No
|
||||||
<!-- Help can be provided if you need assistance submitting a PR -->
|
<!-- Help can be provided if you need assistance submitting a PR -->
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ labels: question
|
|||||||
|
|
||||||
Your question here...
|
Your question here...
|
||||||
|
|
||||||
<!-- Please see our documentation: https://instaloader.github.io/ -->
|
<!--
|
||||||
|
People will be better able and more motivated to provide help if your question
|
||||||
|
|
||||||
<!-- Basic questions regarding Instaloader's usage might be more appropriate to ask on Stack Overflow -->
|
- has a concise and meaningful title,
|
||||||
|
- is not covered by the documentation: https://instaloader.github.io/,
|
||||||
|
- is actually related to Instaloader (this is not a Python help forum),
|
||||||
|
- has not already been answered here or on other sites such as Stack Overflow: https://stackoverflow.com/questions/tagged/instaloader,
|
||||||
|
- is written in a polite and welcoming tone.
|
||||||
|
-->
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
custom: "https://instaloader.github.io/contributing.html#donations"
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Please describe:
|
Thanks for your willingness to contribute to Instaloader! Please briefly
|
||||||
|
describe
|
||||||
|
|
||||||
- A motivation for this change, e.g.
|
- A motivation for this change, e.g.
|
||||||
- Fixes # .
|
- Fixes # .
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [3.8]
|
python-version: [3.8, 3.9]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Instaloader Repository
|
- name: Checkout Instaloader Repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
@@ -20,11 +20,12 @@ jobs:
|
|||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install pipenv==2020.6.2
|
python -m pip install pipenv==2020.11.15
|
||||||
pipenv sync --dev
|
pipenv sync --dev
|
||||||
- name: PyLint
|
- name: PyLint
|
||||||
run: pipenv run pylint instaloader
|
run: pipenv run pylint instaloader
|
||||||
- name: MyPy
|
- name: MyPy
|
||||||
run: pipenv run mypy -m instaloader
|
run: pipenv run mypy -m instaloader
|
||||||
- name: Build Documentation
|
- name: Build Documentation
|
||||||
|
if: matrix.python-version == '3.8'
|
||||||
run: pipenv run make -C docs html SPHINXOPTS="-W -n"
|
run: pipenv run make -C docs html SPHINXOPTS="-W -n"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v1
|
uses: actions/setup-python@v1
|
||||||
with:
|
with:
|
||||||
python-version: 3.8
|
python-version: 3.9
|
||||||
architecture: x64
|
architecture: x64
|
||||||
- name: Get the tagged version
|
- name: Get the tagged version
|
||||||
id: get_version
|
id: get_version
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
dist: xenial
|
dist: xenial
|
||||||
language: python
|
language: python
|
||||||
python:
|
python:
|
||||||
- "3.5"
|
|
||||||
- "3.6"
|
- "3.6"
|
||||||
- "3.7"
|
- "3.7"
|
||||||
install:
|
install:
|
||||||
|
|||||||
@@ -6,3 +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)
|
||||||
|
- ... and many more, see https://github.com/instaloader/instaloader/graphs/contributors
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
|
||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement by opening an
|
||||||
|
issue or contacting one or more of the project maintainers.
|
||||||
|
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by
|
||||||
|
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available
|
||||||
|
at [https://www.contributor-covenant.org/translations][translations].
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
|
||||||
|
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||||
|
[FAQ]: https://www.contributor-covenant.org/faq
|
||||||
|
[translations]: https://www.contributor-covenant.org/translations
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
The MIT License (MIT)
|
The MIT License (MIT)
|
||||||
|
|
||||||
Copyright (c) 2016-2019 Alexander Graf and André Koch-Kramer.
|
Copyright (c) 2016-2020 Alexander Graf and André Koch-Kramer.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ verify_ssl = true
|
|||||||
[dev-packages]
|
[dev-packages]
|
||||||
pylint = "*"
|
pylint = "*"
|
||||||
mypy = "*"
|
mypy = "*"
|
||||||
sphinx = "*"
|
sphinx = "~=2.3"
|
||||||
pyinstaller = "*"
|
pyinstaller = "*"
|
||||||
pefile = "*"
|
pefile = "*"
|
||||||
pywin32-ctypes = "*"
|
pywin32-ctypes = "*"
|
||||||
psutil = "*"
|
psutil = "*"
|
||||||
|
typing-extensions = "*"
|
||||||
|
|
||||||
[packages]
|
[packages]
|
||||||
requests = "*"
|
requests = "*"
|
||||||
|
|||||||
Generated
+272
-166
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"hash": {
|
"hash": {
|
||||||
"sha256": "33408a60fda1a56bf680462fad21643ae0159bc13f3b85d8f10ebf3eef7d8725"
|
"sha256": "9704331c2f9e76af9e5ee386bc359a46ee66d1e2f93aa880d9d148da7ba0e6f5"
|
||||||
},
|
},
|
||||||
"pipfile-spec": 6,
|
"pipfile-spec": 6,
|
||||||
"requires": {},
|
"requires": {},
|
||||||
@@ -16,39 +16,42 @@
|
|||||||
"default": {
|
"default": {
|
||||||
"certifi": {
|
"certifi": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
|
"sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
|
||||||
"sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
|
"sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
|
||||||
],
|
],
|
||||||
"version": "==2019.11.28"
|
"version": "==2020.12.5"
|
||||||
},
|
},
|
||||||
"chardet": {
|
"chardet": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
|
"sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
|
||||||
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
|
"sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
|
||||||
],
|
],
|
||||||
"version": "==3.0.4"
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||||
|
"version": "==4.0.0"
|
||||||
},
|
},
|
||||||
"idna": {
|
"idna": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
|
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
||||||
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
|
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
||||||
],
|
],
|
||||||
"version": "==2.8"
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||||
|
"version": "==2.10"
|
||||||
},
|
},
|
||||||
"requests": {
|
"requests": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
|
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
|
||||||
"sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
|
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2.22.0"
|
"version": "==2.25.1"
|
||||||
},
|
},
|
||||||
"urllib3": {
|
"urllib3": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
|
"sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df",
|
||||||
"sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
|
"sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"
|
||||||
],
|
],
|
||||||
"version": "==1.25.8"
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
|
||||||
|
"version": "==1.26.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"develop": {
|
"develop": {
|
||||||
@@ -68,98 +71,119 @@
|
|||||||
},
|
},
|
||||||
"astroid": {
|
"astroid": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:71ea07f44df9568a75d0f354c49143a4575d90645e9fead6dfb52c26a85ed13a",
|
"sha256:21d735aab248253531bb0f1e1e6d068f0ee23533e18ae8a6171ff892b98297cf",
|
||||||
"sha256:840947ebfa8b58f318d42301cf8c0a20fd794a33b61cc4638e28e9e61ba32f42"
|
"sha256:cfc35498ee64017be059ceffab0a25bedf7548ab76f2bea691c5565896e7128d"
|
||||||
],
|
],
|
||||||
"version": "==2.3.3"
|
"markers": "python_version >= '3.6'",
|
||||||
|
"version": "==2.5.1"
|
||||||
},
|
},
|
||||||
"babel": {
|
"babel": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38",
|
"sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5",
|
||||||
"sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"
|
"sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05"
|
||||||
],
|
],
|
||||||
"version": "==2.8.0"
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||||
|
"version": "==2.9.0"
|
||||||
},
|
},
|
||||||
"certifi": {
|
"certifi": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
|
"sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
|
||||||
"sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
|
"sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
|
||||||
],
|
],
|
||||||
"version": "==2019.11.28"
|
"version": "==2020.12.5"
|
||||||
},
|
},
|
||||||
"chardet": {
|
"chardet": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
|
"sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
|
||||||
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
|
"sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
|
||||||
],
|
],
|
||||||
"version": "==3.0.4"
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||||
|
"version": "==4.0.0"
|
||||||
},
|
},
|
||||||
"docutils": {
|
"docutils": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af",
|
"sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af",
|
||||||
"sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"
|
"sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"
|
||||||
],
|
],
|
||||||
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||||
"version": "==0.16"
|
"version": "==0.16"
|
||||||
},
|
},
|
||||||
"future": {
|
"future": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"
|
"sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"
|
||||||
],
|
],
|
||||||
|
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||||
"version": "==0.18.2"
|
"version": "==0.18.2"
|
||||||
},
|
},
|
||||||
"idna": {
|
"idna": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
|
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
||||||
"sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"
|
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
||||||
],
|
],
|
||||||
"version": "==2.8"
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||||
|
"version": "==2.10"
|
||||||
},
|
},
|
||||||
"imagesize": {
|
"imagesize": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1",
|
"sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1",
|
||||||
"sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"
|
"sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"
|
||||||
],
|
],
|
||||||
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||||
"version": "==1.2.0"
|
"version": "==1.2.0"
|
||||||
},
|
},
|
||||||
|
"importlib-metadata": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:742add720a20d0467df2f444ae41704000f50e1234f46174b51f9c6031a1bd71",
|
||||||
|
"sha256:b74159469b464a99cb8cc3e21973e4d96e05d3024d337313fedb618a6e86e6f4"
|
||||||
|
],
|
||||||
|
"markers": "python_version < '3.8'",
|
||||||
|
"version": "==3.7.3"
|
||||||
|
},
|
||||||
"isort": {
|
"isort": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1",
|
"sha256:c729845434366216d320e936b8ad6f9d681aab72dc7cbc2d51bedc3582f3ad1e",
|
||||||
"sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"
|
"sha256:fff4f0c04e1825522ce6949973e83110a6e907750cd92d128b0d14aaaadbffdc"
|
||||||
],
|
],
|
||||||
"version": "==4.3.21"
|
"markers": "python_version >= '3.6' and python_version < '4.0'",
|
||||||
|
"version": "==5.7.0"
|
||||||
},
|
},
|
||||||
"jinja2": {
|
"jinja2": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250",
|
"sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419",
|
||||||
"sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49"
|
"sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"
|
||||||
],
|
],
|
||||||
"version": "==2.11.1"
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||||
|
"version": "==2.11.3"
|
||||||
},
|
},
|
||||||
"lazy-object-proxy": {
|
"lazy-object-proxy": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d",
|
"sha256:1d33d6f789697f401b75ce08e73b1de567b947740f768376631079290118ad39",
|
||||||
"sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449",
|
"sha256:2f2de8f8ac0be3e40d17730e0600619d35c78c13a099ea91ef7fb4ad944ce694",
|
||||||
"sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08",
|
"sha256:3782931963dc89e0e9a0ae4348b44762e868ea280e4f8c233b537852a8996ab9",
|
||||||
"sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a",
|
"sha256:37d9c34b96cca6787fe014aeb651217944a967a5b165e2cacb6b858d2997ab84",
|
||||||
"sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50",
|
"sha256:38c3865bd220bd983fcaa9aa11462619e84a71233bafd9c880f7b1cb753ca7fa",
|
||||||
"sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd",
|
"sha256:429c4d1862f3fc37cd56304d880f2eae5bd0da83bdef889f3bd66458aac49128",
|
||||||
"sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239",
|
"sha256:522b7c94b524389f4a4094c4bf04c2b02228454ddd17c1a9b2801fac1d754871",
|
||||||
"sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb",
|
"sha256:57fb5c5504ddd45ed420b5b6461a78f58cbb0c1b0cbd9cd5a43ad30a4a3ee4d0",
|
||||||
"sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea",
|
"sha256:5944a9b95e97de1980c65f03b79b356f30a43de48682b8bdd90aa5089f0ec1f4",
|
||||||
"sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e",
|
"sha256:6f4e5e68b7af950ed7fdb594b3f19a0014a3ace0fedb86acb896e140ffb24302",
|
||||||
"sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156",
|
"sha256:71a1ef23f22fa8437974b2d60fedb947c99a957ad625f83f43fd3de70f77f458",
|
||||||
"sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142",
|
"sha256:8a44e9901c0555f95ac401377032f6e6af66d8fc1fbfad77a7a8b1a826e0b93c",
|
||||||
"sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442",
|
"sha256:b6577f15d5516d7d209c1a8cde23062c0f10625f19e8dc9fb59268859778d7d7",
|
||||||
"sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62",
|
"sha256:c8fe2d6ff0ff583784039d0255ea7da076efd08507f2be6f68583b0da32e3afb",
|
||||||
"sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db",
|
"sha256:cadfa2c2cf54d35d13dc8d231253b7985b97d629ab9ca6e7d672c35539d38163",
|
||||||
"sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531",
|
"sha256:cd1bdace1a8762534e9a36c073cd54e97d517a17d69a17985961265be6d22847",
|
||||||
"sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383",
|
"sha256:ddbdcd10eb999d7ab292677f588b658372aadb9a52790f82484a37127a390108",
|
||||||
"sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a",
|
"sha256:e7273c64bccfd9310e9601b8f4511d84730239516bada26a0c9846c9697617ef",
|
||||||
"sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357",
|
"sha256:e7428977763150b4cf83255625a80a23dfdc94d43be7791ce90799d446b4e26f",
|
||||||
"sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4",
|
"sha256:e960e8be509e8d6d618300a6c189555c24efde63e85acaf0b14b2cd1ac743315",
|
||||||
"sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"
|
"sha256:ecb5dd5990cec6e7f5c9c1124a37cb2c710c6d69b0c1a5c4aa4b35eba0ada068",
|
||||||
|
"sha256:ef3f5e288aa57b73b034ce9c1f1ac753d968f9069cd0742d1d69c698a0167166",
|
||||||
|
"sha256:fa5b2dee0e231fa4ad117be114251bdfe6afe39213bd629d43deb117b6a6c40a",
|
||||||
|
"sha256:fa7fb7973c622b9e725bee1db569d2c2ee64d2f9a089201c5e8185d482c7352d"
|
||||||
],
|
],
|
||||||
"version": "==1.4.3"
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||||
|
"version": "==1.5.2"
|
||||||
},
|
},
|
||||||
"markupsafe": {
|
"markupsafe": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -168,8 +192,12 @@
|
|||||||
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
|
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
|
||||||
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
|
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
|
||||||
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
|
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
|
||||||
|
"sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f",
|
||||||
|
"sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39",
|
||||||
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
|
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
|
||||||
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
|
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
|
||||||
|
"sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014",
|
||||||
|
"sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f",
|
||||||
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
|
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
|
||||||
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
|
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
|
||||||
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
|
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
|
||||||
@@ -178,25 +206,41 @@
|
|||||||
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
|
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
|
||||||
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
|
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
|
||||||
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
|
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
|
||||||
|
"sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85",
|
||||||
|
"sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1",
|
||||||
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
|
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
|
||||||
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
|
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
|
||||||
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
|
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
|
||||||
|
"sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850",
|
||||||
|
"sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0",
|
||||||
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
|
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
|
||||||
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
|
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
|
||||||
|
"sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb",
|
||||||
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
|
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
|
||||||
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
|
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
|
||||||
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
|
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
|
||||||
|
"sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1",
|
||||||
|
"sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2",
|
||||||
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
|
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
|
||||||
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
|
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
|
||||||
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
|
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
|
||||||
|
"sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7",
|
||||||
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
|
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
|
||||||
|
"sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8",
|
||||||
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
|
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
|
||||||
|
"sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193",
|
||||||
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
|
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
|
||||||
|
"sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b",
|
||||||
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
|
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
|
||||||
"sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
|
"sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
|
||||||
|
"sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5",
|
||||||
|
"sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c",
|
||||||
|
"sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032",
|
||||||
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
|
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
|
||||||
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
|
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be",
|
||||||
|
"sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"
|
||||||
],
|
],
|
||||||
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||||
"version": "==1.1.1"
|
"version": "==1.1.1"
|
||||||
},
|
},
|
||||||
"mccabe": {
|
"mccabe": {
|
||||||
@@ -208,23 +252,31 @@
|
|||||||
},
|
},
|
||||||
"mypy": {
|
"mypy": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a",
|
"sha256:0d0a87c0e7e3a9becdfbe936c981d32e5ee0ccda3e0f07e1ef2c3d1a817cf73e",
|
||||||
"sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7",
|
"sha256:25adde9b862f8f9aac9d2d11971f226bd4c8fbaa89fb76bdadb267ef22d10064",
|
||||||
"sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2",
|
"sha256:28fb5479c494b1bab244620685e2eb3c3f988d71fd5d64cc753195e8ed53df7c",
|
||||||
"sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474",
|
"sha256:2f9b3407c58347a452fc0736861593e105139b905cca7d097e413453a1d650b4",
|
||||||
"sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0",
|
"sha256:33f159443db0829d16f0a8d83d94df3109bb6dd801975fe86bacb9bf71628e97",
|
||||||
"sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217",
|
"sha256:3f2aca7f68580dc2508289c729bd49ee929a436208d2b2b6aab15745a70a57df",
|
||||||
"sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749",
|
"sha256:499c798053cdebcaa916eef8cd733e5584b5909f789de856b482cd7d069bdad8",
|
||||||
"sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6",
|
"sha256:4eec37370483331d13514c3f55f446fc5248d6373e7029a29ecb7b7494851e7a",
|
||||||
"sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf",
|
"sha256:552a815579aa1e995f39fd05dde6cd378e191b063f031f2acfe73ce9fb7f9e56",
|
||||||
"sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36",
|
"sha256:5873888fff1c7cf5b71efbe80e0e73153fe9212fafdf8e44adfe4c20ec9f82d7",
|
||||||
"sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b",
|
"sha256:61a3d5b97955422964be6b3baf05ff2ce7f26f52c85dd88db11d5e03e146a3a6",
|
||||||
"sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72",
|
"sha256:674e822aa665b9fd75130c6c5f5ed9564a38c6cea6a6432ce47eafb68ee578c5",
|
||||||
"sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1",
|
"sha256:7ce3175801d0ae5fdfa79b4f0cfed08807af4d075b402b7e294e6aa72af9aa2a",
|
||||||
"sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1"
|
"sha256:9743c91088d396c1a5a3c9978354b61b0382b4e3c440ce83cf77994a43e8c521",
|
||||||
|
"sha256:9f94aac67a2045ec719ffe6111df543bac7874cee01f41928f6969756e030564",
|
||||||
|
"sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49",
|
||||||
|
"sha256:abf7e0c3cf117c44d9285cc6128856106183938c68fd4944763003decdcfeb66",
|
||||||
|
"sha256:b09669bcda124e83708f34a94606e01b614fa71931d356c1f1a5297ba11f110a",
|
||||||
|
"sha256:cd07039aa5df222037005b08fbbfd69b3ab0b0bd7a07d7906de75ae52c4e3119",
|
||||||
|
"sha256:d23e0ea196702d918b60c8288561e722bf437d82cb7ef2edcd98cfa38905d506",
|
||||||
|
"sha256:d65cc1df038ef55a99e617431f0553cd77763869eebdf9042403e16089fe746c",
|
||||||
|
"sha256:d7da2e1d5f558c37d6e8c1246f1aec1e7349e4913d8fb3cb289a35de573fe2eb"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==0.761"
|
"version": "==0.812"
|
||||||
},
|
},
|
||||||
"mypy-extensions": {
|
"mypy-extensions": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -235,10 +287,11 @@
|
|||||||
},
|
},
|
||||||
"packaging": {
|
"packaging": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:170748228214b70b672c581a3dd610ee51f733018650740e98c7df862a583f73",
|
"sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5",
|
||||||
"sha256:e665345f9eef0c621aa0bf2f8d78cf6d21904eef16a93f020240b704a57f1334"
|
"sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"
|
||||||
],
|
],
|
||||||
"version": "==20.1"
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||||
|
"version": "==20.9"
|
||||||
},
|
},
|
||||||
"pefile": {
|
"pefile": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -249,56 +302,82 @@
|
|||||||
},
|
},
|
||||||
"psutil": {
|
"psutil": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:094f899ac3ef72422b7e00411b4ed174e3c5a2e04c267db6643937ddba67a05b",
|
"sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64",
|
||||||
"sha256:10b7f75cc8bd676cfc6fa40cd7d5c25b3f45a0e06d43becd7c2d2871cbb5e806",
|
"sha256:02b8292609b1f7fcb34173b25e48d0da8667bc85f81d7476584d889c6e0f2131",
|
||||||
"sha256:1b1575240ca9a90b437e5a40db662acd87bbf181f6aa02f0204978737b913c6b",
|
"sha256:0ae6f386d8d297177fd288be6e8d1afc05966878704dad9847719650e44fc49c",
|
||||||
"sha256:21231ef1c1a89728e29b98a885b8e0a8e00d09018f6da5cdc1f43f988471a995",
|
"sha256:0c9ccb99ab76025f2f0bbecf341d4656e9c1351db8cc8a03ccd62e318ab4b5c6",
|
||||||
"sha256:28f771129bfee9fc6b63d83a15d857663bbdcae3828e1cb926e91320a9b5b5cd",
|
"sha256:0dd4465a039d343925cdc29023bb6960ccf4e74a65ad53e768403746a9207023",
|
||||||
"sha256:70387772f84fa5c3bb6a106915a2445e20ac8f9821c5914d7cbde148f4d7ff73",
|
"sha256:12d844996d6c2b1d3881cfa6fa201fd635971869a9da945cf6756105af73d2df",
|
||||||
"sha256:b560f5cd86cf8df7bcd258a851ca1ad98f0d5b8b98748e877a0aec4e9032b465",
|
"sha256:1bff0d07e76114ec24ee32e7f7f8d0c4b0514b3fae93e3d2aaafd65d22502394",
|
||||||
"sha256:b74b43fecce384a57094a83d2778cdfc2e2d9a6afaadd1ebecb2e75e0d34e10d",
|
"sha256:245b5509968ac0bd179287d91210cd3f37add77dad385ef238b275bad35fa1c4",
|
||||||
"sha256:e85f727ffb21539849e6012f47b12f6dd4c44965e56591d8dec6e8bc9ab96f4a",
|
"sha256:28ff7c95293ae74bf1ca1a79e8805fcde005c18a122ca983abf676ea3466362b",
|
||||||
"sha256:fd2e09bb593ad9bdd7429e779699d2d47c1268cbde4dda95fcd1bd17544a0217",
|
"sha256:36b3b6c9e2a34b7d7fbae330a85bf72c30b1c827a4366a07443fc4b6270449e2",
|
||||||
"sha256:ffad8eb2ac614518bbe3c0b8eb9dffdb3a8d2e3a7d5da51c5b974fb723a5c5aa"
|
"sha256:52de075468cd394ac98c66f9ca33b2f54ae1d9bff1ef6b67a212ee8f639ec06d",
|
||||||
|
"sha256:5da29e394bdedd9144c7331192e20c1f79283fb03b06e6abd3a8ae45ffecee65",
|
||||||
|
"sha256:61f05864b42fedc0771d6d8e49c35f07efd209ade09a5afe6a5059e7bb7bf83d",
|
||||||
|
"sha256:6223d07a1ae93f86451d0198a0c361032c4c93ebd4bf6d25e2fb3edfad9571ef",
|
||||||
|
"sha256:6323d5d845c2785efb20aded4726636546b26d3b577aded22492908f7c1bdda7",
|
||||||
|
"sha256:6ffe81843131ee0ffa02c317186ed1e759a145267d54fdef1bc4ea5f5931ab60",
|
||||||
|
"sha256:74f2d0be88db96ada78756cb3a3e1b107ce8ab79f65aa885f76d7664e56928f6",
|
||||||
|
"sha256:74fb2557d1430fff18ff0d72613c5ca30c45cdbfcddd6a5773e9fc1fe9364be8",
|
||||||
|
"sha256:90d4091c2d30ddd0a03e0b97e6a33a48628469b99585e2ad6bf21f17423b112b",
|
||||||
|
"sha256:90f31c34d25b1b3ed6c40cdd34ff122b1887a825297c017e4cbd6796dd8b672d",
|
||||||
|
"sha256:99de3e8739258b3c3e8669cb9757c9a861b2a25ad0955f8e53ac662d66de61ac",
|
||||||
|
"sha256:c6a5fd10ce6b6344e616cf01cc5b849fa8103fbb5ba507b6b2dee4c11e84c935",
|
||||||
|
"sha256:ce8b867423291cb65cfc6d9c4955ee9bfc1e21fe03bb50e177f2b957f1c2469d",
|
||||||
|
"sha256:d225cd8319aa1d3c85bf195c4e07d17d3cd68636b8fc97e6cf198f782f99af28",
|
||||||
|
"sha256:ea313bb02e5e25224e518e4352af4bf5e062755160f77e4b1767dd5ccb65f876",
|
||||||
|
"sha256:ea372bcc129394485824ae3e3ddabe67dc0b118d262c568b4d2602a7070afdb0",
|
||||||
|
"sha256:f4634b033faf0d968bb9220dd1c793b897ab7f1189956e1aa9eae752527127d3",
|
||||||
|
"sha256:fcc01e900c1d7bee2a37e5d6e4f9194760a93597c97fee89c4ae51701de03563"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==5.6.7"
|
"version": "==5.8.0"
|
||||||
},
|
},
|
||||||
"pygments": {
|
"pygments": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b",
|
"sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94",
|
||||||
"sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe"
|
"sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8"
|
||||||
],
|
],
|
||||||
"version": "==2.5.2"
|
"markers": "python_version >= '3.5'",
|
||||||
|
"version": "==2.8.1"
|
||||||
},
|
},
|
||||||
"pyinstaller": {
|
"pyinstaller": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:3730fa80d088f8bb7084d32480eb87cbb4ddb64123363763cf8f2a1378c1c4b7"
|
"sha256:f5c0eeb2aa663cce9a5404292c0195011fa500a6501c873a466b2e8cad3c950c"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==3.6"
|
"version": "==4.2"
|
||||||
|
},
|
||||||
|
"pyinstaller-hooks-contrib": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:27558072021857d89524c42136feaa2ffe4f003f1bdf0278f9b24f6902c1759c",
|
||||||
|
"sha256:892310e6363655838485ee748bf1c5e5cade7963686d9af8650ee218a3e0b031"
|
||||||
|
],
|
||||||
|
"version": "==2021.1"
|
||||||
},
|
},
|
||||||
"pylint": {
|
"pylint": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:3db5468ad013380e987410a8d6956226963aed94ecb5f9d3a28acca6d9ac36cd",
|
"sha256:0e21d3b80b96740909d77206d741aa3ce0b06b41be375d92e1f3244a274c1f8a",
|
||||||
"sha256:886e6afc935ea2590b462664b161ca9a5e40168ea99e5300935f6591ad467df4"
|
"sha256:d09b0b07ba06bcdff463958f53f23df25e740ecd81895f7d2699ec04bbd8dc3b"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2.4.4"
|
"version": "==2.7.2"
|
||||||
},
|
},
|
||||||
"pyparsing": {
|
"pyparsing": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f",
|
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
|
||||||
"sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec"
|
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
|
||||||
],
|
],
|
||||||
"version": "==2.4.6"
|
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||||
|
"version": "==2.4.7"
|
||||||
},
|
},
|
||||||
"pytz": {
|
"pytz": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d",
|
"sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da",
|
||||||
"sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"
|
"sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"
|
||||||
],
|
],
|
||||||
"version": "==2019.3"
|
"version": "==2021.1"
|
||||||
},
|
},
|
||||||
"pywin32-ctypes": {
|
"pywin32-ctypes": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -310,122 +389,149 @@
|
|||||||
},
|
},
|
||||||
"requests": {
|
"requests": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
|
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
|
||||||
"sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
|
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2.22.0"
|
"version": "==2.25.1"
|
||||||
},
|
|
||||||
"six": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
|
|
||||||
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
|
|
||||||
],
|
|
||||||
"version": "==1.14.0"
|
|
||||||
},
|
},
|
||||||
"snowballstemmer": {
|
"snowballstemmer": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0",
|
"sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2",
|
||||||
"sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"
|
"sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"
|
||||||
],
|
],
|
||||||
"version": "==2.0.0"
|
"version": "==2.1.0"
|
||||||
},
|
},
|
||||||
"sphinx": {
|
"sphinx": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:298537cb3234578b2d954ff18c5608468229e116a9757af3b831c2b2b4819159",
|
"sha256:b4c750d546ab6d7e05bdff6ac24db8ae3e8b8253a3569b754e445110a0a12b66",
|
||||||
"sha256:e6e766b74f85f37a5f3e0773a1e1be8db3fcb799deb58ca6d18b70b0b44542a5"
|
"sha256:fc312670b56cb54920d6cc2ced455a22a547910de10b3142276495ced49231cb"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2.3.1"
|
"version": "==2.4.4"
|
||||||
},
|
},
|
||||||
"sphinxcontrib-applehelp": {
|
"sphinxcontrib-applehelp": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897",
|
"sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a",
|
||||||
"sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d"
|
"sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"
|
||||||
],
|
],
|
||||||
"version": "==1.0.1"
|
"markers": "python_version >= '3.5'",
|
||||||
|
"version": "==1.0.2"
|
||||||
},
|
},
|
||||||
"sphinxcontrib-devhelp": {
|
"sphinxcontrib-devhelp": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34",
|
"sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e",
|
||||||
"sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981"
|
"sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"
|
||||||
],
|
],
|
||||||
"version": "==1.0.1"
|
"markers": "python_version >= '3.5'",
|
||||||
|
"version": "==1.0.2"
|
||||||
},
|
},
|
||||||
"sphinxcontrib-htmlhelp": {
|
"sphinxcontrib-htmlhelp": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422",
|
"sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f",
|
||||||
"sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7"
|
"sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"
|
||||||
],
|
],
|
||||||
"version": "==1.0.2"
|
"markers": "python_version >= '3.5'",
|
||||||
|
"version": "==1.0.3"
|
||||||
},
|
},
|
||||||
"sphinxcontrib-jsmath": {
|
"sphinxcontrib-jsmath": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178",
|
"sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178",
|
||||||
"sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"
|
"sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"
|
||||||
],
|
],
|
||||||
|
"markers": "python_version >= '3.5'",
|
||||||
"version": "==1.0.1"
|
"version": "==1.0.1"
|
||||||
},
|
},
|
||||||
"sphinxcontrib-qthelp": {
|
"sphinxcontrib-qthelp": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20",
|
"sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72",
|
||||||
"sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f"
|
"sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"
|
||||||
],
|
],
|
||||||
"version": "==1.0.2"
|
"markers": "python_version >= '3.5'",
|
||||||
|
"version": "==1.0.3"
|
||||||
},
|
},
|
||||||
"sphinxcontrib-serializinghtml": {
|
"sphinxcontrib-serializinghtml": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227",
|
"sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc",
|
||||||
"sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768"
|
"sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"
|
||||||
],
|
],
|
||||||
"version": "==1.1.3"
|
"markers": "python_version >= '3.5'",
|
||||||
|
"version": "==1.1.4"
|
||||||
|
},
|
||||||
|
"toml": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
|
||||||
|
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||||
|
"version": "==0.10.2"
|
||||||
},
|
},
|
||||||
"typed-ast": {
|
"typed-ast": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
|
"sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1",
|
||||||
"sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
|
"sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d",
|
||||||
"sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
|
"sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6",
|
||||||
"sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
|
"sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd",
|
||||||
"sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
|
"sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37",
|
||||||
"sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
|
"sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151",
|
||||||
"sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
|
"sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07",
|
||||||
"sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
|
"sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440",
|
||||||
"sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
|
"sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70",
|
||||||
"sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
|
"sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496",
|
||||||
"sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
|
"sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea",
|
||||||
"sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
|
"sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400",
|
||||||
"sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
|
"sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc",
|
||||||
"sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
|
"sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606",
|
||||||
"sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
|
"sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc",
|
||||||
"sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
|
"sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581",
|
||||||
"sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
|
"sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412",
|
||||||
"sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
|
"sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a",
|
||||||
"sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
|
"sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2",
|
||||||
"sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
|
"sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787",
|
||||||
"sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
|
"sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f",
|
||||||
|
"sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937",
|
||||||
|
"sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64",
|
||||||
|
"sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487",
|
||||||
|
"sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b",
|
||||||
|
"sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41",
|
||||||
|
"sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a",
|
||||||
|
"sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3",
|
||||||
|
"sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166",
|
||||||
|
"sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"
|
||||||
],
|
],
|
||||||
"version": "==1.4.1"
|
"markers": "python_version < '3.8' and implementation_name == 'cpython'",
|
||||||
|
"version": "==1.4.2"
|
||||||
},
|
},
|
||||||
"typing-extensions": {
|
"typing-extensions": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2",
|
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
|
||||||
"sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d",
|
"sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
|
||||||
"sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575"
|
"sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
|
||||||
],
|
],
|
||||||
"version": "==3.7.4.1"
|
"index": "pypi",
|
||||||
|
"version": "==3.7.4.3"
|
||||||
},
|
},
|
||||||
"urllib3": {
|
"urllib3": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
|
"sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df",
|
||||||
"sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
|
"sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"
|
||||||
],
|
],
|
||||||
"version": "==1.25.8"
|
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
|
||||||
|
"version": "==1.26.4"
|
||||||
},
|
},
|
||||||
"wrapt": {
|
"wrapt": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1"
|
"sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"
|
||||||
],
|
],
|
||||||
"version": "==1.11.2"
|
"version": "==1.12.1"
|
||||||
|
},
|
||||||
|
"zipp": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76",
|
||||||
|
"sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.6'",
|
||||||
|
"version": "==3.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -27,8 +27,9 @@
|
|||||||
:alt: Arch User Repository Package
|
:alt: Arch User Repository Package
|
||||||
:target: https://aur.archlinux.org/packages/instaloader/
|
:target: https://aur.archlinux.org/packages/instaloader/
|
||||||
|
|
||||||
.. |downloads| image:: https://img.shields.io/pypi/dm/instaloader.svg
|
.. |downloads| image:: https://pepy.tech/badge/instaloader/month
|
||||||
:alt: PyPI Download Count
|
:alt: PyPI Download Count
|
||||||
|
:target: https://pepy.tech/project/instaloader
|
||||||
|
|
||||||
.. badges-end
|
.. badges-end
|
||||||
|
|
||||||
@@ -49,7 +50,9 @@
|
|||||||
directory accordingly,
|
directory accordingly,
|
||||||
|
|
||||||
- allows **fine-grained customization** of filters and where to store
|
- allows **fine-grained customization** of filters and where to store
|
||||||
downloaded media.
|
downloaded media,
|
||||||
|
|
||||||
|
- automatically **resumes previously-interrupted** download iterations.
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|
||||||
@@ -81,7 +84,9 @@ To later **update your local copy** of that profiles, you may run
|
|||||||
instaloader --fast-update profile [profile ...]
|
instaloader --fast-update profile [profile ...]
|
||||||
|
|
||||||
If ``--fast-update`` is given, Instaloader stops when arriving at the
|
If ``--fast-update`` is given, Instaloader stops when arriving at the
|
||||||
first already-downloaded picture. When updating profiles, Instaloader
|
first already-downloaded picture.
|
||||||
|
|
||||||
|
When updating profiles, Instaloader
|
||||||
automatically **detects profile name changes** and renames the target directory
|
automatically **detects profile name changes** and renames the target directory
|
||||||
accordingly.
|
accordingly.
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ arch=('any')
|
|||||||
url="https://instaloader.github.io/"
|
url="https://instaloader.github.io/"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
groups=()
|
groups=()
|
||||||
depends=('python>=3.5' 'python-requests>=2.4')
|
depends=('python>=3.6' 'python-requests>=2.4')
|
||||||
makedepends=('python-setuptools')
|
makedepends=('python-setuptools')
|
||||||
options=('!emptydirs')
|
options=('!emptydirs')
|
||||||
source=($pkgname-$pkgver.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v$pkgver)
|
source=($pkgname-$pkgver.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v$pkgver)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import subprocess
|
|||||||
def __main():
|
def __main():
|
||||||
with contextlib.suppress(AttributeError, psutil.Error):
|
with contextlib.suppress(AttributeError, psutil.Error):
|
||||||
if psutil.Process().parent().parent().name() == "explorer.exe":
|
if psutil.Process().parent().parent().name() == "explorer.exe":
|
||||||
subprocess.Popen("powershell -NoExit -Command \\\"& {0}\\\"".format(sys.argv[0]))
|
subprocess.Popen("powershell -NoExit -Command \\\"& '{0}'\\\"".format(sys.argv[0]))
|
||||||
return
|
return
|
||||||
main()
|
main()
|
||||||
|
|
||||||
@@ -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==2018.11.26",
|
commands = ["pip install pipenv==2020.11.15",
|
||||||
"pipenv sync --dev",
|
"pipenv sync --dev",
|
||||||
"pipenv run pyinstaller --log-level=DEBUG instaloader.spec"]
|
"pipenv run pyinstaller --log-level=DEBUG instaloader.spec"]
|
||||||
|
|
||||||
|
|||||||
-7
File diff suppressed because one or more lines are too long
Vendored
-7
File diff suppressed because one or more lines are too long
Vendored
-78
@@ -1,78 +0,0 @@
|
|||||||
@import url(bootstrap-4.1.3.min.css);
|
|
||||||
a {
|
|
||||||
color: #008d06; }
|
|
||||||
a:hover, a.current, a.active {
|
|
||||||
color: #f48400; }
|
|
||||||
a.current, a.active {
|
|
||||||
font-weight: 500; }
|
|
||||||
|
|
||||||
.doc-sidebar a:hover, .modal-body a:hover {
|
|
||||||
text-decoration: none; }
|
|
||||||
|
|
||||||
code {
|
|
||||||
color: #212529; }
|
|
||||||
|
|
||||||
.highlight {
|
|
||||||
border-radius: 0.3em; }
|
|
||||||
.highlight pre {
|
|
||||||
padding: 0.7em;
|
|
||||||
color: #fff; }
|
|
||||||
.highlight .c1 {
|
|
||||||
color: #666; }
|
|
||||||
.highlight .k, .highlight .kn, .highlight .ow {
|
|
||||||
color: #008d06; }
|
|
||||||
.highlight .nb, .highlight .ne, .highlight .nf, .highlight .vm {
|
|
||||||
color: #f48400; }
|
|
||||||
.highlight .s1, .highlight .s2, .highlight .sa, .highlight .se, .highlight .si, .highlight .mi {
|
|
||||||
color: #ff7efd; }
|
|
||||||
.highlight .c1, .highlight .nf, .highlight .sa, .highlight .se, .highlight .si {
|
|
||||||
font-style: italic; }
|
|
||||||
.highlight .k, .highlight .kn, .highlight .ow {
|
|
||||||
font-weight: bold; }
|
|
||||||
|
|
||||||
.doc-content {
|
|
||||||
padding-top: 0.5em;
|
|
||||||
top: 3.5625em;
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
overflow-y: auto; }
|
|
||||||
|
|
||||||
.doc-sidebar {
|
|
||||||
position: fixed;
|
|
||||||
padding-top: 0.5em;
|
|
||||||
top: 3.5625em;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
overflow-y: auto; }
|
|
||||||
.doc-sidebar ul {
|
|
||||||
list-style-type: none;
|
|
||||||
padding-left: 1em; }
|
|
||||||
.doc-sidebar ul.current {
|
|
||||||
padding-left: 0; }
|
|
||||||
.doc-sidebar li {
|
|
||||||
padding-top: 0.2em;
|
|
||||||
padding-bottom: 0.2em; }
|
|
||||||
|
|
||||||
.admonition {
|
|
||||||
border-radius: 0.3em;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
background-color: #f8f9fa; }
|
|
||||||
.admonition .admonition-title {
|
|
||||||
color: #ff7efd; }
|
|
||||||
|
|
||||||
.topic {
|
|
||||||
border-radius: 0.3em;
|
|
||||||
background-color: #f8f9fa; }
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
padding-top: 0;
|
|
||||||
padding-bottom: 0; }
|
|
||||||
|
|
||||||
.btn-success {
|
|
||||||
background-color: #008d06;
|
|
||||||
border-color: #008d06; }
|
|
||||||
.btn-success:hover {
|
|
||||||
background-color: #f48400;
|
|
||||||
border-color: #f48400; }
|
|
||||||
|
|
||||||
/*# sourceMappingURL=instaloader.css.map */
|
|
||||||
Vendored
-22
@@ -1,22 +0,0 @@
|
|||||||
$(function () {
|
|
||||||
$('[data-toggle="tooltip"]').tooltip();
|
|
||||||
|
|
||||||
$('.doc-sidebar > ul > li.current').attr("id", "localtoc");
|
|
||||||
$('#localtoc ul').addClass("nav flex-column");
|
|
||||||
$('#localtoc ul li').addClass("nav-item");
|
|
||||||
$('#localtoc ul li a').addClass("nav-link");
|
|
||||||
$('.doc-content').scrollspy({target: '#localtoc'});
|
|
||||||
|
|
||||||
const top_href = '#' + $('.section:first').attr("id");
|
|
||||||
$('#localtoc > a.current').attr("href", top_href);
|
|
||||||
|
|
||||||
$('#navbarToc a').on("click", function () {
|
|
||||||
const href = $(this).attr("href");
|
|
||||||
if (href === '#') {
|
|
||||||
window.location.href = top_href;
|
|
||||||
} else {
|
|
||||||
window.location.href = href;
|
|
||||||
}
|
|
||||||
$('#navbarToc').modal('hide');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Vendored
-124
@@ -1,124 +0,0 @@
|
|||||||
@import 'bootstrap-4.1.3.min.css';
|
|
||||||
|
|
||||||
$color_instaloader_main: #008d06;
|
|
||||||
$color_instaloader_accent: #f48400;
|
|
||||||
$color_instaloader_hyper: #ff7efd;
|
|
||||||
$color_normal_text: #212529;
|
|
||||||
$color_light_bg: #f8f9fa;
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: $color_instaloader_main;
|
|
||||||
|
|
||||||
&:hover, &.current, &.active {
|
|
||||||
color: $color_instaloader_accent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.current, &.active {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-sidebar, .modal-body {
|
|
||||||
a:hover {
|
|
||||||
text-decoration: none
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
color: $color_normal_text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight {
|
|
||||||
border-radius: 0.3em;
|
|
||||||
|
|
||||||
pre {
|
|
||||||
padding: 0.7em;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c1 {
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.k, .kn, .ow {
|
|
||||||
color: $color_instaloader_main
|
|
||||||
}
|
|
||||||
|
|
||||||
.nb, .ne, .nf, .vm {
|
|
||||||
color: $color_instaloader_accent
|
|
||||||
}
|
|
||||||
|
|
||||||
.s1, .s2, .sa, .se, .si, .mi {
|
|
||||||
color: $color_instaloader_hyper
|
|
||||||
}
|
|
||||||
|
|
||||||
.c1, .nf, .sa, .se, .si {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.k, .kn, .ow {
|
|
||||||
font-weight: bold
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-content {
|
|
||||||
padding-top: 0.5em;
|
|
||||||
top: 3.5625em;
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doc-sidebar {
|
|
||||||
position: fixed;
|
|
||||||
padding-top: 0.5em;
|
|
||||||
top: 3.5625em;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style-type: none;
|
|
||||||
padding-left: 1em;
|
|
||||||
|
|
||||||
&.current {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
padding-top: 0.2em;
|
|
||||||
padding-bottom: 0.2em;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.admonition {
|
|
||||||
border-radius: 0.3em;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
background-color: $color_light_bg;
|
|
||||||
|
|
||||||
.admonition-title {
|
|
||||||
color: $color_instaloader_hyper;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.topic {
|
|
||||||
border-radius: 0.3em;
|
|
||||||
background-color: $color_light_bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
padding-top: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success {
|
|
||||||
background-color: $color_instaloader_main;
|
|
||||||
border-color: $color_instaloader_main;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $color_instaloader_accent;
|
|
||||||
border-color: $color_instaloader_accent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+50
File diff suppressed because one or more lines are too long
Vendored
+46
-78
@@ -1,9 +1,6 @@
|
|||||||
{% extends "!layout.html" %}
|
{% extends "!layout.html" %}
|
||||||
|
|
||||||
{% set html5_doctype = True %}
|
{% set html5_doctype = True %}
|
||||||
|
{% set script_files = script_files + ['_static/instaloaderdoc.js'] %}
|
||||||
{% set script_files = script_files + ['_static/bootstrap-4.1.3.bundle.min.js', '_static/instaloader.js'] %}
|
|
||||||
|
|
||||||
{% block htmltitle %}
|
{% block htmltitle %}
|
||||||
{% if pagename == "index" %}
|
{% if pagename == "index" %}
|
||||||
<title>{{ title|striptags|e }}</title>
|
<title>{{ title|striptags|e }}</title>
|
||||||
@@ -11,7 +8,6 @@
|
|||||||
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
|
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extrahead %}
|
{% block extrahead %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
@@ -22,94 +18,66 @@
|
|||||||
<link rel="canonical" href="https://instaloader.github.io/{{ pagename }}.html"/>
|
<link rel="canonical" href="https://instaloader.github.io/{{ pagename }}.html"/>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<nav class="navbar navbar-expand-sm navbar-light bg-light fixed-top 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, 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">
|
||||||
Instaloader
|
<span class="ml-3">Instaloader</span>
|
||||||
</a>
|
</a>
|
||||||
<ul class="navbar-nav mr-auto">
|
<ul class="navbar-nav mr-auto">
|
||||||
<li class="nav-item ml-0 ml-sm-2 ml-md-0">
|
<li class="nav-item ml-0 ml-sm-2">
|
||||||
<a target="_blank" class="nav-link"
|
<a class="nav-link"
|
||||||
href="https://github.com/instaloader/instaloader/releases/tag/v{{ current_release }}"
|
href="https://github.com/instaloader/instaloader/releases/tag/v{{ current_release }}"
|
||||||
title="Released on {{ current_release_date }}" data-toggle="tooltip">v<b>{{ current_release }}</b></a>
|
title="Released on {{ current_release_date }}" data-toggle="tooltip">
|
||||||
|
v<b>{{ current_release }}</b>
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarLinks"
|
<ul class="navbar-nav ml-auto d-none d-sm-flex">
|
||||||
aria-controls="navbarLinks" aria-expanded="false" aria-label="Toggle navigation">
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="https://github.com/instaloader/instaloader/issues">
|
||||||
|
Issues
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="https://github.com/instaloader/instaloader/releases">
|
||||||
|
Releases
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="https://github.com/instaloader/instaloader">
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button class="d-inline-block d-md-none btn btn-outline-primary btn-sm" type="button"
|
||||||
|
data-toggle="collapse" data-target="#instaloaderdocSidebar" aria-controls="instaloaderdocSidebar"
|
||||||
|
aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse" id="navbarLinks">
|
|
||||||
<ul class="navbar-nav ml-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a target="_blank" class="nav-link" href="https://github.com/instaloader/instaloader/issues">Issues</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a target="_blank" class="nav-link" href="https://github.com/instaloader/instaloader/releases">Releases</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a target="_blank" class="nav-link" href="https://github.com/instaloader/instaloader">GitHub</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<form class="form-inline" action="search.html" method="get">
|
|
||||||
<div class="input-group my-2 my-sm-0 ml-2">
|
|
||||||
<input class="form-control" type="search" placeholder="Search" aria-label="Search" name="q">
|
|
||||||
<div class="input-group-append">
|
|
||||||
<button class="btn btn-success btn-small" type="submit">Go</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% if pagename != "index" %}
|
|
||||||
<button type="button" class="btn btn-success d-md-none btn-small ml-2" data-toggle="modal"
|
|
||||||
data-target="#navbarToc">
|
|
||||||
TOC
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</nav>
|
</nav>
|
||||||
{% if pagename != "index" %}
|
|
||||||
<div class="modal fade" id="navbarToc" tabindex="-1" role="dialog"
|
|
||||||
aria-hidden="true" aria-labelledby="navbarTocTitle">
|
|
||||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="navbarTocTitle">Contents</h5>
|
|
||||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
|
||||||
<span aria-hidden="true">×</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
{{ toctree(maxdepth=-1) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block relbar1 %}{% endblock %}
|
{% block relbar1 %}{% endblock %}
|
||||||
{% block relbar2 %}{% endblock %}
|
{% block relbar2 %}{% endblock %}
|
||||||
{% block footer %}{% endblock %}
|
{% block footer %}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid row">
|
<div class="container-fluid">
|
||||||
<div class="col-12 {% if pagename != "index" %}col-md-9 col-xl-10{% endif %} doc-content">
|
<div class="row">
|
||||||
{% block body %}{% endblock %}
|
<div class="instaloaderdoc-sidebar collapse d-md-block col-md-3 col-xl-2 bg-light"
|
||||||
</div>
|
id="instaloaderdocSidebar">
|
||||||
{% if pagename != "index" %}
|
<nav role="navigation" aria-label="Table of Contents">
|
||||||
<div class="d-none col-md-3 col-xl-2 bg-light doc-sidebar d-md-flex flex-column border-left">
|
<form action="{{ pathto('search') }}" method="get" role="search" class="border-bottom mr-2 pb-2 mb-2 px-1">
|
||||||
|
<label class="sr-only" for="searchQueryInput">Search</label>
|
||||||
<h3>{{ _('Table of Contents') }}</h3>
|
<input type="search" name="q" id="searchQueryInput" class="form-control my-2"
|
||||||
{{ toctree(maxdepth=-1) }}
|
placeholder="Search..." aria-label="Search">
|
||||||
|
</form>
|
||||||
<div class="d-flex justify-content-between small mt-auto mb-2">
|
<h4>Contents</h4>
|
||||||
<a class="reference internal{% if pagename == "genindex" %} current{% endif %}" href="genindex.html">Index</a>
|
{{ toctree(maxdepth=-1) }}
|
||||||
{% if pagename != "genindex" and pagename != "search" %}
|
</nav>
|
||||||
<a class="reference external"
|
|
||||||
href="https://github.com/instaloader/instaloader/edit/master/docs/{{ pagename }}.rst">Edit this page</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
<main class="col-12 col-md-9 col-xl-10 pt-2" role="main">
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+14
-140
@@ -12,11 +12,8 @@ Python Module :mod:`instaloader`
|
|||||||
|
|
||||||
.. highlight:: python
|
.. highlight:: python
|
||||||
|
|
||||||
.. contents::
|
|
||||||
:backlinks: none
|
|
||||||
|
|
||||||
Instaloader exposes its internally used methods and structures as a Python
|
Instaloader exposes its internally used methods and structures as a Python
|
||||||
module, making it a **powerful and intuitive Python API for Instagram**,
|
module, making it a powerful and intuitive Python API for Instagram,
|
||||||
allowing to further customize obtaining media and metadata.
|
allowing to further customize obtaining media and metadata.
|
||||||
|
|
||||||
Start with getting an instance of :class:`Instaloader`::
|
Start with getting an instance of :class:`Instaloader`::
|
||||||
@@ -85,149 +82,26 @@ metadata of a Profile. :class:`Profile` instances can be created with:
|
|||||||
- :meth:`Profile.get_followers`
|
- :meth:`Profile.get_followers`
|
||||||
Profiles that follow given user.
|
Profiles that follow given user.
|
||||||
|
|
||||||
- :attr:`Post.owner_profile`, :attr:`Story.owner_profile` and :attr:`StoryItem.owner_profile`
|
- :attr:`Post.owner_profile`, :attr:`Story.owner_profile`, :attr:`StoryItem.owner_profile`
|
||||||
Owner profile of particular object.
|
Owner profile of particular object.
|
||||||
|
|
||||||
- :meth:`Post.get_likes`
|
- :meth:`Post.get_likes`
|
||||||
Profiles that liked a given :class:`Post`
|
Profiles that liked a given :class:`Post`
|
||||||
|
|
||||||
- :attr:`PostComment.owner` attribute for comment in :meth:`Post.get_comments`
|
- :attr:`PostComment.owner`
|
||||||
Profile of a Post comment.
|
Profile of a Post comment.
|
||||||
|
|
||||||
A reference of the many methods provided by the :mod:`instaloader` module is
|
For a list of a few code examples that use the Instaloader module, see
|
||||||
provided in the remainder of this document.
|
:ref:`codesnippets`.
|
||||||
|
|
||||||
For a list of real code examples using the Instaloader module for advanced
|
The reference of the classes and functions provided by the :mod:`instaloader` module is
|
||||||
tasks, see :ref:`codesnippets`.
|
divided into the following subsections:
|
||||||
|
|
||||||
``Instaloader`` (Main Class)
|
.. toctree::
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
:maxdepth: 2
|
||||||
|
|
||||||
.. autoclass:: Instaloader
|
module/instaloader
|
||||||
:no-show-inheritance:
|
module/structures
|
||||||
|
module/nodeiterator
|
||||||
Instagram Structures
|
module/instaloadercontext
|
||||||
^^^^^^^^^^^^^^^^^^^^
|
module/exceptions
|
||||||
|
|
||||||
Posts
|
|
||||||
"""""
|
|
||||||
|
|
||||||
.. autoclass:: Post
|
|
||||||
:no-show-inheritance:
|
|
||||||
|
|
||||||
Additionally, the following trivial structures are defined:
|
|
||||||
|
|
||||||
.. autoclass:: PostSidecarNode
|
|
||||||
:no-show-inheritance:
|
|
||||||
|
|
||||||
.. autoclass:: PostComment
|
|
||||||
:no-show-inheritance:
|
|
||||||
|
|
||||||
.. autoclass:: PostCommentAnswer
|
|
||||||
:no-show-inheritance:
|
|
||||||
|
|
||||||
.. autoclass:: PostLocation
|
|
||||||
:no-show-inheritance:
|
|
||||||
|
|
||||||
User Stories
|
|
||||||
""""""""""""
|
|
||||||
|
|
||||||
.. autoclass:: Story
|
|
||||||
:no-show-inheritance:
|
|
||||||
|
|
||||||
.. autoclass:: StoryItem
|
|
||||||
:no-show-inheritance:
|
|
||||||
|
|
||||||
Highlights
|
|
||||||
""""""""""
|
|
||||||
|
|
||||||
.. autoclass:: Highlight
|
|
||||||
:no-show-inheritance:
|
|
||||||
:inherited-members:
|
|
||||||
|
|
||||||
Bases: :class:`Story`
|
|
||||||
|
|
||||||
.. versionadded:: 4.1
|
|
||||||
|
|
||||||
Profiles
|
|
||||||
""""""""
|
|
||||||
|
|
||||||
.. autoclass:: Profile
|
|
||||||
:no-show-inheritance:
|
|
||||||
|
|
||||||
Hashtags
|
|
||||||
""""""""
|
|
||||||
|
|
||||||
.. autoclass:: Hashtag
|
|
||||||
:no-show-inheritance:
|
|
||||||
|
|
||||||
.. versionadded:: 4.4
|
|
||||||
|
|
||||||
TopSearchResults
|
|
||||||
""""""""""""""""
|
|
||||||
|
|
||||||
.. autoclass:: TopSearchResults
|
|
||||||
:no-show-inheritance:
|
|
||||||
|
|
||||||
.. versionadded:: 4.3
|
|
||||||
|
|
||||||
Loading and Saving
|
|
||||||
""""""""""""""""""
|
|
||||||
|
|
||||||
:class:`Post`, :class:`StoryItem`, :class:`Profile` and :class:`Hashtag` can be saved and loaded
|
|
||||||
to/from JSON files.
|
|
||||||
|
|
||||||
.. autofunction:: load_structure_from_file
|
|
||||||
|
|
||||||
.. autofunction:: save_structure_to_file
|
|
||||||
|
|
||||||
Exceptions
|
|
||||||
^^^^^^^^^^
|
|
||||||
|
|
||||||
.. currentmodule:: instaloader.exceptions
|
|
||||||
|
|
||||||
.. autoexception:: InstaloaderException
|
|
||||||
:no-show-inheritance:
|
|
||||||
|
|
||||||
.. autoexception:: ConnectionException
|
|
||||||
|
|
||||||
.. currentmodule:: instaloader
|
|
||||||
|
|
||||||
.. autoexception:: QueryReturnedBadRequestException
|
|
||||||
|
|
||||||
.. autoexception:: QueryReturnedForbiddenException
|
|
||||||
|
|
||||||
.. autoexception:: ProfileNotExistsException
|
|
||||||
|
|
||||||
.. autoexception:: ProfileHasNoPicsException
|
|
||||||
|
|
||||||
.. autoexception:: PrivateProfileNotFollowedException
|
|
||||||
|
|
||||||
.. autoexception:: LoginRequiredException
|
|
||||||
|
|
||||||
.. autoexception:: TwoFactorAuthRequiredException
|
|
||||||
|
|
||||||
.. versionadded:: 4.2
|
|
||||||
|
|
||||||
.. autoexception:: InvalidArgumentException
|
|
||||||
|
|
||||||
.. autoexception:: BadResponseException
|
|
||||||
|
|
||||||
.. autoexception:: BadCredentialsException
|
|
||||||
|
|
||||||
.. autoexception:: PostChangedException
|
|
||||||
|
|
||||||
.. autoexception:: QueryReturnedNotFoundException
|
|
||||||
|
|
||||||
.. versionchanged:: 4.3
|
|
||||||
QueryReturnedNotFoundException now inherits ConnectionException
|
|
||||||
to retry on 404 errors.
|
|
||||||
|
|
||||||
|
|
||||||
.. autoexception:: TooManyRequestsException
|
|
||||||
|
|
||||||
``InstaloaderContext`` (Low-level functions)
|
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
.. autoclass:: InstaloaderContext
|
|
||||||
:no-show-inheritance:
|
|
||||||
|
|||||||
+20
-16
@@ -77,7 +77,7 @@ Instaloader supports the following targets:
|
|||||||
instructs Instaloader to also **download the user's stories**,
|
instructs Instaloader to also **download the user's stories**,
|
||||||
|
|
||||||
- :option:`--highlights`
|
- :option:`--highlights`
|
||||||
to **download highlights of each profile that is downloaded**,
|
to **download the highlights of that profile**,
|
||||||
|
|
||||||
- :option:`--tagged`
|
- :option:`--tagged`
|
||||||
to **download posts where the user is tagged**, and
|
to **download posts where the user is tagged**, and
|
||||||
@@ -86,7 +86,7 @@ Instaloader supports the following targets:
|
|||||||
to **download IGTV videos**.
|
to **download IGTV videos**.
|
||||||
|
|
||||||
- ``"#hashtag"``
|
- ``"#hashtag"``
|
||||||
Posts with a certain **hashtag** (the quotes are usually necessary),
|
Posts with a certain **hashtag** (the quotes are usually necessary).
|
||||||
|
|
||||||
- ``%location id``
|
- ``%location id``
|
||||||
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
|
||||||
@@ -98,20 +98,20 @@ Instaloader supports the following targets:
|
|||||||
|
|
||||||
- ``:stories``
|
- ``:stories``
|
||||||
The currently-visible **stories** of your followees (requires
|
The currently-visible **stories** of your followees (requires
|
||||||
:option:`--login`),
|
:option:`--login`).
|
||||||
|
|
||||||
- ``:feed``
|
- ``:feed``
|
||||||
Your **feed** (requires :option:`--login`),
|
Your **feed** (requires :option:`--login`).
|
||||||
|
|
||||||
- ``:saved``
|
- ``:saved``
|
||||||
Posts which are marked as **saved** (requires :option:`--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 :option:`--login`).
|
``profile`` (requires :option:`--login`).
|
||||||
|
|
||||||
- ``-post``
|
- ``-post``
|
||||||
Replace **post** with the post's shortcode to download single post. Must be preceeded 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::
|
the argument list to not be mistaken as an option flag::
|
||||||
|
|
||||||
instaloader -- -B_K4CykAOtf
|
instaloader -- -B_K4CykAOtf
|
||||||
@@ -171,6 +171,9 @@ dirname pattern. The following tokens are defined for usage with
|
|||||||
- ``{mediaid}``
|
- ``{mediaid}``
|
||||||
Integer representation of shortcode.
|
Integer representation of shortcode.
|
||||||
|
|
||||||
|
- ``{filename}``
|
||||||
|
Instagram's internal filename.
|
||||||
|
|
||||||
- ``{date_utc}`` (same as ``{date}``)
|
- ``{date_utc}`` (same as ``{date}``)
|
||||||
Creation time in UTC timezone.
|
Creation time in UTC timezone.
|
||||||
`strftime()-style formatting options <https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior>`__
|
`strftime()-style formatting options <https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior>`__
|
||||||
@@ -196,7 +199,7 @@ Filter Posts
|
|||||||
.. py:currentmodule:: instaloader
|
.. py:currentmodule:: instaloader
|
||||||
|
|
||||||
The options :option:`--post-filter` and :option:`--storyitem-filter`
|
The options :option:`--post-filter` and :option:`--storyitem-filter`
|
||||||
allows to specify criteria that posts or story items have to
|
allow to specify criteria that posts or story items have to
|
||||||
meet to be downloaded. If not given, all posts are downloaded.
|
meet to be downloaded. If not given, all posts are downloaded.
|
||||||
|
|
||||||
The filter string must be a
|
The filter string must be a
|
||||||
@@ -208,7 +211,7 @@ 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)
|
||||||
Owner profile username / userid.
|
Owner profile username / user ID.
|
||||||
|
|
||||||
- :attr:`~Post.date_utc` (datetime), :attr:`~Post.date_local` (datetime)
|
- :attr:`~Post.date_utc` (datetime), :attr:`~Post.date_local` (datetime)
|
||||||
Creation timestamp. Since :class:`~datetime.datetime` objects can be created
|
Creation timestamp. Since :class:`~datetime.datetime` objects can be created
|
||||||
@@ -217,7 +220,7 @@ Id est, the following attributes can be used with both
|
|||||||
instaloader --post-filter="date_utc <= datetime(2018, 5, 31)" target
|
instaloader --post-filter="date_utc <= datetime(2018, 5, 31)" target
|
||||||
|
|
||||||
- :attr:`~Post.is_video` (bool)
|
- :attr:`~Post.is_video` (bool)
|
||||||
Post/StoryItem is a video. For example, you may skip videos::
|
Whether Post/StoryItem is a video. For example, you may skip videos::
|
||||||
|
|
||||||
instaloader --post-filter="not is_video" target
|
instaloader --post-filter="not is_video" target
|
||||||
|
|
||||||
@@ -234,8 +237,8 @@ As :option:`--post-filter`, the following attributes can be used additionally:
|
|||||||
instaloader --login=your_username --post-filter=viewer_has_liked :feed
|
instaloader --login=your_username --post-filter=viewer_has_liked :feed
|
||||||
|
|
||||||
- :attr:`~Post.likes` (int), :attr:`~Post.comments` (int)
|
- :attr:`~Post.likes` (int), :attr:`~Post.comments` (int)
|
||||||
Likes count / Comments count. You might only want to download posts that
|
Likes count / comments count. You might only want to download posts that
|
||||||
either you liked or were liked by many others**::
|
were either liked by yourself or by many others::
|
||||||
|
|
||||||
instaloader --login=your_username --post-filter="likes>100 or viewer_has_liked" profile
|
instaloader --login=your_username --post-filter="likes>100 or viewer_has_liked" profile
|
||||||
|
|
||||||
@@ -265,7 +268,7 @@ along with each post where the Post's caption is saved.
|
|||||||
You can customize what metadata to save for each Post or StoryItem with
|
You can customize what metadata to save for each Post or StoryItem with
|
||||||
:option:`--post-metadata-txt` and :option:`--storyitem-metadata-txt`. The
|
:option:`--post-metadata-txt` and :option:`--storyitem-metadata-txt`. The
|
||||||
default is ``--post-metadata-txt={caption}`` and no storyitem metadata txt.
|
default is ``--post-metadata-txt={caption}`` and no storyitem metadata txt.
|
||||||
These strings are formatted similar as in the :ref:`filename-specification` and
|
These strings are formatted similar as the path patterns described in :ref:`filename-specification` and
|
||||||
the result is saved in text files, unless it is empty.
|
the result is saved in text files, unless it is empty.
|
||||||
|
|
||||||
Specifying these options multiple times results in output having multiple lines,
|
Specifying these options multiple times results in output having multiple lines,
|
||||||
@@ -299,10 +302,10 @@ Instaloader logged-in, create a session file::
|
|||||||
|
|
||||||
instaloader --login=your_username
|
instaloader --login=your_username
|
||||||
|
|
||||||
Then use the same parameter in your cronjob to load the session and download
|
Then use the same username in your cronjob to load the session and download
|
||||||
the given targets::
|
the given targets::
|
||||||
|
|
||||||
instaloader --login=your_username --quiet <target> [...]
|
instaloader --login=your_username --quiet target [...]
|
||||||
|
|
||||||
Instaloader saves the session file to
|
Instaloader saves the session file to
|
||||||
``~/.config/instaloader/session-YOUR-USERNAME``. See
|
``~/.config/instaloader/session-YOUR-USERNAME``. See
|
||||||
@@ -317,5 +320,6 @@ Instaloader exposes its internally used methods and structures, making it a
|
|||||||
powerful and intuitive Python API for Instagram, allowing to further customize
|
powerful and intuitive Python API for Instagram, allowing to further customize
|
||||||
obtaining media and metadata.
|
obtaining media and metadata.
|
||||||
|
|
||||||
Also see :ref:`codesnippets`, where we collect example scripts that use
|
Also see :ref:`codesnippets`, where we collect a few example scripts that use
|
||||||
Instaloader to achieve more complex tasks.
|
Instaloader for simple tasks that cannot be done with the command line
|
||||||
|
interface.
|
||||||
|
|||||||
+69
-48
@@ -36,7 +36,7 @@ stores all posts along with the pictures's captions there.
|
|||||||
Re-Download the given object
|
Re-Download the given object
|
||||||
|
|
||||||
- ``+args.txt``
|
- ``+args.txt``
|
||||||
Read targets (and options) from given textfile. See :option:`+args.txt`.
|
Read targets (and options) from given text file. See :option:`+args.txt`.
|
||||||
|
|
||||||
What to Download of each Post
|
What to Download of each Post
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
@@ -59,7 +59,7 @@ What to Download of each Post
|
|||||||
|
|
||||||
.. option:: --geotags, -G
|
.. option:: --geotags, -G
|
||||||
|
|
||||||
**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 :option:`--login`.
|
request to the Instagram server for each picture. Requires :option:`--login`.
|
||||||
|
|
||||||
@@ -82,6 +82,16 @@ What to Download of each Post
|
|||||||
Template to write in txt file for each StoryItem. See
|
Template to write in txt file for each StoryItem. See
|
||||||
:ref:`metadata-text-files`.
|
:ref:`metadata-text-files`.
|
||||||
|
|
||||||
|
.. option:: --slide
|
||||||
|
|
||||||
|
Download only selected images of a sidecar. You can select single images using their
|
||||||
|
index in the sidecar starting with the leftmost or you can specify a range of images
|
||||||
|
with the following syntax: ``start_index-end_index``. Example:
|
||||||
|
``--slide 1`` will select only the first image, ``--slide last`` only the last one and ``--slide 1-3`` will select only
|
||||||
|
the first three images.
|
||||||
|
|
||||||
|
.. versionadded:: 4.6
|
||||||
|
|
||||||
.. option:: --no-metadata-json
|
.. option:: --no-metadata-json
|
||||||
|
|
||||||
Do not create a JSON file containing the metadata of each post.
|
Do not create a JSON file containing the metadata of each post.
|
||||||
@@ -94,13 +104,6 @@ What to Download of each Post
|
|||||||
What to Download of each Profile
|
What to Download of each Profile
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
.. option:: --profile-pic-only, -P
|
|
||||||
|
|
||||||
.. deprecated:: 4.1
|
|
||||||
Use :option:`--no-posts`.
|
|
||||||
|
|
||||||
Only download profile picture.
|
|
||||||
|
|
||||||
.. option:: --no-posts
|
.. option:: --no-posts
|
||||||
|
|
||||||
Do not download regular posts.
|
Do not download regular posts.
|
||||||
@@ -113,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
|
||||||
:option:`--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
|
||||||
:option:`--login`.
|
:option:`--login`.
|
||||||
|
|
||||||
.. versionadded:: 4.1
|
.. versionadded:: 4.1
|
||||||
@@ -135,20 +138,6 @@ What to Download of each Profile
|
|||||||
|
|
||||||
.. versionadded:: 4.3
|
.. versionadded:: 4.3
|
||||||
|
|
||||||
.. option:: --stories-only
|
|
||||||
|
|
||||||
.. deprecated:: 4.1
|
|
||||||
Use :option:`--stories` :option:`--no-posts`.
|
|
||||||
|
|
||||||
Rather than downloading regular posts of each specified profile, only
|
|
||||||
download stories. Requires :option:`--login`. Does not imply
|
|
||||||
:option:`--no-profile-pic`.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
If possible, use ``:stories`` target rather than :option:`--stories-only`
|
|
||||||
with all your followees. ``:stories`` uses fewer API requests.
|
|
||||||
|
|
||||||
Which Posts to Download
|
Which Posts to Download
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
@@ -182,7 +171,7 @@ Which Posts to Download
|
|||||||
Login (Download Private Profiles)
|
Login (Download Private Profiles)
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
Instaloader can **login to Instagram**. This allows downloading private
|
Instaloader can login to Instagram. This allows downloading private
|
||||||
profiles. To login, pass the :option:`--login` option. Your session cookie (not your
|
profiles. To login, pass the :option:`--login` option. Your session cookie (not your
|
||||||
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.
|
||||||
@@ -223,10 +212,41 @@ How to Download
|
|||||||
``#hashtag`` or the profile name. Defaults to ``{date_utc}_UTC``.
|
``#hashtag`` or the profile name. Defaults to ``{date_utc}_UTC``.
|
||||||
See :ref:`filename-specification` for a list of supported tokens.
|
See :ref:`filename-specification` for a list of supported tokens.
|
||||||
|
|
||||||
|
.. option:: --resume-prefix prefix
|
||||||
|
|
||||||
|
For many targets, Instaloader is capable of resuming a previously-aborted
|
||||||
|
download loop. To do so, it creates a JSON file within the target directory
|
||||||
|
when interrupted. This option controls the prefix for filenames that are
|
||||||
|
used to save the information to resume an interrupted download. The default
|
||||||
|
prefix is ``iterator``.
|
||||||
|
|
||||||
|
Resuming an interrupted download is supported for the following targets:
|
||||||
|
- Profile posts,
|
||||||
|
- Profile IGTV posts (:option:`--igtv`),
|
||||||
|
- Profile tagged posts (:option:`--tagged`),
|
||||||
|
- Saved posts (``:saved``).
|
||||||
|
|
||||||
|
This feature is enabled by default for targets where it is supported;
|
||||||
|
:option:`--resume-prefix` only changes the name of the iterator files.
|
||||||
|
|
||||||
|
To turn this feature off, use :option:`--no-resume`.
|
||||||
|
|
||||||
|
JSON files with resume information are always compressed, regardless of
|
||||||
|
:option:`--no-compress-json`.
|
||||||
|
|
||||||
|
.. versionadded:: 4.5
|
||||||
|
|
||||||
|
.. option:: --no-resume
|
||||||
|
|
||||||
|
Do not resume a previously-aborted download iteration, and do not save such
|
||||||
|
information when interrupted.
|
||||||
|
|
||||||
|
.. versionadded:: 4.5
|
||||||
|
|
||||||
.. 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/51.
|
Chrome/89 on Linux.
|
||||||
|
|
||||||
.. option:: --max-connection-attempts N
|
.. option:: --max-connection-attempts N
|
||||||
|
|
||||||
@@ -234,25 +254,26 @@ How to Download
|
|||||||
to ``3``. If a connection fails, it can be manually skipped by hitting
|
to ``3``. If a connection fails, it can be manually skipped by hitting
|
||||||
:kbd:`Control-c`. Set this to ``0`` to retry infinitely.
|
:kbd:`Control-c`. Set this to ``0`` to retry infinitely.
|
||||||
|
|
||||||
.. option:: --commit-mode
|
|
||||||
|
|
||||||
Tries to ensure downloaded images avoid corruption in case of unexpected
|
|
||||||
interruption. If the last picture is corrupted, Instaloader will fix the
|
|
||||||
picture the next time it is run.
|
|
||||||
|
|
||||||
.. versionadded:: 4.2
|
|
||||||
|
|
||||||
.. warning::
|
|
||||||
We discourage from using the "commit mode" due to its bugs when being used
|
|
||||||
with other command line options (e.g. :issue:`257` with
|
|
||||||
:option:`--fast-update` and :issue:`483` with :option:`--tagged`).
|
|
||||||
|
|
||||||
.. option:: --request-timeout N
|
.. option:: --request-timeout N
|
||||||
|
|
||||||
Seconds to wait before timing out a connection request.
|
Seconds to wait before timing out a connection request. Defaults to 300.
|
||||||
|
|
||||||
.. versionadded:: 4.3
|
.. versionadded:: 4.3
|
||||||
|
|
||||||
|
.. versionchanged:: 4.6
|
||||||
|
Enabled this option by default with a timeout of 300 seconds.
|
||||||
|
|
||||||
|
.. option:: --abort-on STATUS_CODE_LIST
|
||||||
|
|
||||||
|
Comma-separated list of HTTP status codes that cause Instaloader to abort,
|
||||||
|
bypassing all retry logic.
|
||||||
|
|
||||||
|
For example, with ``--abort-on=302,400,429``, Instaloader will stop if a
|
||||||
|
request is responded with a 302 redirect, a Bad Request error, or a Too Many
|
||||||
|
Requests error.
|
||||||
|
|
||||||
|
.. versionadded:: 4.7
|
||||||
|
|
||||||
Miscellaneous Options
|
Miscellaneous Options
|
||||||
^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
@@ -266,18 +287,18 @@ Miscellaneous Options
|
|||||||
|
|
||||||
Read arguments from file `args.txt`, a shortcut to provide arguments from
|
Read arguments from file `args.txt`, a shortcut to provide arguments from
|
||||||
file rather than command-line. This provides a convenient way to hide login
|
file rather than command-line. This provides a convenient way to hide login
|
||||||
info from CLI, and can also be used to simplify managment of long arguments.
|
info from CLI, and can also be used to simplify management of long arguments.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
Text file should separate arguments with line breaks.
|
Text file should separate arguments with line breaks.
|
||||||
|
|
||||||
args.txt example::
|
args.txt example::
|
||||||
|
|
||||||
--login=MYUSERNAME
|
--login=MYUSERNAME
|
||||||
--password=MYPASSWORD
|
--password=MYPASSWORD
|
||||||
--fast-update
|
--fast-update
|
||||||
profile1
|
profile1
|
||||||
profile2
|
profile2
|
||||||
|
|
||||||
.. versionadded:: 4.1
|
.. versionadded:: 4.1
|
||||||
|
|||||||
+8
-33
@@ -42,7 +42,7 @@ The code example with :func:`~itertools.dropwhile` and
|
|||||||
:func:`~itertools.takewhile` makes the assumption that the post iterator returns
|
:func:`~itertools.takewhile` makes the assumption that the post iterator returns
|
||||||
posts in exact chronological order. As discussed in :issue:`666`, the following
|
posts in exact chronological order. As discussed in :issue:`666`, the following
|
||||||
approach fits for an **almost chronological order**, where up to *k* older posts
|
approach fits for an **almost chronological order**, where up to *k* older posts
|
||||||
are inserted into an otherwise chronological order, such as an Hashtag feed.
|
are inserted into an otherwise chronological order, such as a Hashtag feed.
|
||||||
|
|
||||||
.. literalinclude:: codesnippets/666_historical_hashtag_data.py
|
.. literalinclude:: codesnippets/666_historical_hashtag_data.py
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ Likes of a Profile / Ghost Followers
|
|||||||
------------------------------------
|
------------------------------------
|
||||||
|
|
||||||
To obtain a list of your inactive followers, i.e. followers that did not like
|
To obtain a list of your inactive followers, i.e. followers that did not like
|
||||||
any of your pictures, into a file you can use this approach.
|
any of your pictures, you can use this approach.
|
||||||
|
|
||||||
.. literalinclude:: codesnippets/120_ghost_followers.py
|
.. literalinclude:: codesnippets/120_ghost_followers.py
|
||||||
|
|
||||||
@@ -78,11 +78,11 @@ Discussed in :issue:`56`.
|
|||||||
Only one Post per User
|
Only one Post per User
|
||||||
----------------------
|
----------------------
|
||||||
|
|
||||||
To download only the one most recent post from each user, this snippet creates a
|
To download only the single most recent post per user within a hashtag feed,
|
||||||
:class:`set` that contains the users of which a post has already been
|
this snippet uses a :class:`set` that contains the users of whom a post has
|
||||||
downloaded. While iterating the posts, it checks whether the post's owner
|
already been downloaded. For each post, it checks whether the post's creator is
|
||||||
already is in the set. If not, the post is downloaded from Instagram and the
|
already contained in that set. If not, the post is downloaded from Instagram and
|
||||||
user is added to that set.
|
the user is added to that set.
|
||||||
|
|
||||||
.. literalinclude:: codesnippets/113_only_one_per_user.py
|
.. literalinclude:: codesnippets/113_only_one_per_user.py
|
||||||
|
|
||||||
@@ -94,37 +94,12 @@ Discussed in :issue:`113`.
|
|||||||
Top X Posts of User
|
Top X Posts of User
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
With Instaloader, it is easy to download the few most-liked pictres of a user.
|
With Instaloader, it is easy to download the few most-liked pictures of a user.
|
||||||
|
|
||||||
.. literalinclude:: codesnippets/194_top_x_of_user.py
|
.. literalinclude:: codesnippets/194_top_x_of_user.py
|
||||||
|
|
||||||
Discussed in :issue:`194`.
|
Discussed in :issue:`194`.
|
||||||
|
|
||||||
Upgrade Images by Local Copies
|
|
||||||
------------------------------
|
|
||||||
|
|
||||||
The following script finds local versions of images fetched by Instaloader, in
|
|
||||||
order to upgrade the downloaded images by locally-found versions with better
|
|
||||||
quality. It uses image hashing to identify similar images.
|
|
||||||
|
|
||||||
`updgrade-instaloader-images.py <https://gist.github.com/pavelkryukov/15f93d19a99428a284a8bcec27e0187b>`__ (external link to GitHub Gist)
|
|
||||||
|
|
||||||
Discussed in :issue:`46`.
|
|
||||||
|
|
||||||
Add Captions to Images
|
|
||||||
----------------------
|
|
||||||
|
|
||||||
Instaloader does not modify the downloaded JPEG file. However, one could combine
|
|
||||||
it with an imaging library such as Pillow or PIL to render the caption on
|
|
||||||
Instagram pictures. The following shows an approach.
|
|
||||||
|
|
||||||
.. literalinclude:: codesnippets/110_pil_captions.py
|
|
||||||
|
|
||||||
See also :attr:`Post.caption`, :attr:`Post.url`, :meth:`Post.from_shortcode`,
|
|
||||||
:func:`load_structure_from_file`.
|
|
||||||
|
|
||||||
Discussed in :issue:`110`.
|
|
||||||
|
|
||||||
Metadata JSON Files
|
Metadata JSON Files
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
from io import BytesIO
|
|
||||||
|
|
||||||
from requests import get
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
from instaloader import *
|
|
||||||
|
|
||||||
L = Instaloader()
|
|
||||||
|
|
||||||
# Load Post instance
|
|
||||||
post = load_structure_from_file(L.context, '2017-10-01_18-53-03_UTC.json.xz')
|
|
||||||
# or post = Post.from_shortcode(L.context, SHORTCODE)
|
|
||||||
|
|
||||||
# Render caption
|
|
||||||
image = Image.open(BytesIO(get(post.url).content))
|
|
||||||
draw = ImageDraw.Draw(image)
|
|
||||||
color = 'rgb(0, 0, 0)' # black color
|
|
||||||
draw.text((300,100), post.caption.encode('latin1', errors='ignore'), fill=color)
|
|
||||||
|
|
||||||
# Save image
|
|
||||||
image.save('test.jpg')
|
|
||||||
@@ -2,26 +2,26 @@ import instaloader
|
|||||||
|
|
||||||
L = instaloader.Instaloader()
|
L = instaloader.Instaloader()
|
||||||
|
|
||||||
USER = 'your_account'
|
USER = "your_account"
|
||||||
PROFILE = USER
|
PROFILE = USER
|
||||||
|
|
||||||
# Your preferred way of logging in:
|
# Load session previously saved with `instaloader -l USERNAME`:
|
||||||
L.load_session_from_file(USER)
|
L.load_session_from_file(USER)
|
||||||
|
|
||||||
profile = instaloader.Profile.from_username(L.context, PROFILE)
|
profile = instaloader.Profile.from_username(L.context, PROFILE)
|
||||||
|
|
||||||
likes = set()
|
likes = set()
|
||||||
print('Fetching likes of all posts of profile {}.'.format(profile.username))
|
print("Fetching likes of all posts of profile {}.".format(profile.username))
|
||||||
for post in profile.get_posts():
|
for post in profile.get_posts():
|
||||||
print(post)
|
print(post)
|
||||||
likes = likes | set(post.get_likes())
|
likes = likes | set(post.get_likes())
|
||||||
|
|
||||||
print('Fetching followers of profile {}.'.format(profile.username))
|
print("Fetching followers of profile {}.".format(profile.username))
|
||||||
followers = set(profile.get_followers())
|
followers = set(profile.get_followers())
|
||||||
|
|
||||||
ghosts = followers - likes
|
ghosts = followers - likes
|
||||||
|
|
||||||
print('Storing ghosts into file.')
|
print("Storing ghosts into file.")
|
||||||
with open('/YOUR PATH/inactive-users.txt', 'w') as f:
|
with open("inactive-users.txt", 'w') as f:
|
||||||
for ghost in ghosts:
|
for ghost in ghosts:
|
||||||
print(ghost.username, file=f)
|
print(ghost.username, file=f)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def get_cookiefile():
|
|||||||
|
|
||||||
def import_session(cookiefile, sessionfile):
|
def import_session(cookiefile, sessionfile):
|
||||||
print("Using cookies from {}.".format(cookiefile))
|
print("Using cookies from {}.".format(cookiefile))
|
||||||
conn = connect(cookiefile)
|
conn = connect(f"file:{cookiefile}?immutable=1", uri=True)
|
||||||
try:
|
try:
|
||||||
cookie_data = conn.execute(
|
cookie_data = conn.execute(
|
||||||
"SELECT name, value FROM moz_cookies WHERE baseDomain='instagram.com'"
|
"SELECT name, value FROM moz_cookies WHERE baseDomain='instagram.com'"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ SINCE = datetime(2020, 5, 10) # further from today, inclusive
|
|||||||
UNTIL = datetime(2020, 5, 11) # closer to today, not inclusive
|
UNTIL = datetime(2020, 5, 11) # closer to today, not inclusive
|
||||||
|
|
||||||
k = 0 # initiate k
|
k = 0 # initiate k
|
||||||
k_list = [] # uncomment this to tune k
|
#k_list = [] # uncomment this to tune k
|
||||||
|
|
||||||
for post in posts:
|
for post in posts:
|
||||||
postdate = post.date
|
postdate = post.date
|
||||||
@@ -24,7 +24,8 @@ for post in posts:
|
|||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
L.download_post(post, "#urbanphotography")
|
L.download_post(post, "#urbanphotography")
|
||||||
k = 0 # set k to 0
|
|
||||||
# if you want to tune k, uncomment below to get your k max
|
# if you want to tune k, uncomment below to get your k max
|
||||||
#k_list.append(k)
|
#k_list.append(k)
|
||||||
|
k = 0 # set k to 0
|
||||||
|
|
||||||
#max(k_list)
|
#max(k_list)
|
||||||
|
|||||||
+4
-2
@@ -50,6 +50,8 @@ autodoc_member_order = 'bysource'
|
|||||||
intersphinx_mapping = {'python': ('https://docs.python.org/3', None),
|
intersphinx_mapping = {'python': ('https://docs.python.org/3', None),
|
||||||
'requests': ('https://requests.kennethreitz.org/en/master/', None)}
|
'requests': ('https://requests.kennethreitz.org/en/master/', None)}
|
||||||
|
|
||||||
|
nitpick_ignore = [('py:class', 'typing.Tuple')]
|
||||||
|
|
||||||
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"
|
||||||
current_release_date = subprocess.check_output(
|
current_release_date = subprocess.check_output(
|
||||||
@@ -63,7 +65,7 @@ extlinks = {
|
|||||||
'Issue #'
|
'Issue #'
|
||||||
),
|
),
|
||||||
'example': (
|
'example': (
|
||||||
'https://raw.githubusercontent.com/instaloader/instaloader/v' + current_release + '/docs/codesnippets/%s',
|
'https://raw.githubusercontent.com/instaloader/instaloader/master/docs/codesnippets/%s',
|
||||||
'Example '
|
'Example '
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -372,6 +374,6 @@ texinfo_documents = [
|
|||||||
|
|
||||||
def setup(app):
|
def setup(app):
|
||||||
typing.TYPE_CHECKING = True
|
typing.TYPE_CHECKING = True
|
||||||
app.add_stylesheet("instaloader.css")
|
app.add_stylesheet("instaloaderdoc.css")
|
||||||
app.connect('autodoc-process-signature', sphinx_autodoc_typehints.process_signature)
|
app.connect('autodoc-process-signature', sphinx_autodoc_typehints.process_signature)
|
||||||
app.connect('autodoc-process-docstring', sphinx_autodoc_typehints.process_docstring)
|
app.connect('autodoc-process-docstring', sphinx_autodoc_typehints.process_docstring)
|
||||||
|
|||||||
+48
-14
@@ -11,6 +11,23 @@ Instaloader's development is organized on
|
|||||||
`GitHub <https://github.com/instaloader/instaloader>`__, where Issues and Pull
|
`GitHub <https://github.com/instaloader/instaloader>`__, where Issues and Pull
|
||||||
Requests are discussed.
|
Requests are discussed.
|
||||||
|
|
||||||
|
Contributing to an open source project is an unmatchable opportunity to improve
|
||||||
|
and manifest programming skills. For issues that are good starting points to get
|
||||||
|
involved in Instaloader development, as they do not require deep insight into
|
||||||
|
Instaloader's code base, have a look at our
|
||||||
|
`Issues labelled 'good first issue' <https://github.com/instaloader/instaloader/contribute>`__.
|
||||||
|
|
||||||
|
This document covers several ways of how you can contribute back to Instaloader.
|
||||||
|
|
||||||
|
Answering Questions
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
The easiest way to help out is to answer questions. If you are interested in
|
||||||
|
answering questions regarding Instaloader, good places to begin are
|
||||||
|
|
||||||
|
- `Questions tagged 'instaloader' on Stack Overflow <https://stackoverflow.com/questions/tagged/instaloader>`__,
|
||||||
|
- `Instaloader Issues labeled 'question' <https://github.com/instaloader/instaloader/issues?q=is%3Aissue+is%3Aopen+label%3Aquestion>`__.
|
||||||
|
|
||||||
Reporting Bugs
|
Reporting Bugs
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
@@ -32,7 +49,7 @@ reporting a problem, please keep the following in mind:
|
|||||||
|
|
||||||
- Include all **error messages and tracebacks** in the report.
|
- Include all **error messages and tracebacks** in the report.
|
||||||
|
|
||||||
- If not obvious, describe **which behavior you expected**
|
- Even if it seems obvious, describe **which behavior you expected**
|
||||||
instead of what actually happened.
|
instead of what actually happened.
|
||||||
|
|
||||||
- If we have closed an issue apparently inadvertently or inappropriately, please
|
- If we have closed an issue apparently inadvertently or inappropriately, please
|
||||||
@@ -41,22 +58,39 @@ reporting a problem, please keep the following in mind:
|
|||||||
Writing Code or Improving the Documentation
|
Writing Code or Improving the Documentation
|
||||||
-------------------------------------------
|
-------------------------------------------
|
||||||
|
|
||||||
Changes of the Instaloader source can be proposed as a
|
Improvements of the Instaloader source or its documentation can be proposed as a
|
||||||
`Pull Request <https://github.com/instaloader/instaloader/pulls>`__. There are only
|
`Pull Request <https://github.com/instaloader/instaloader/pulls>`__.
|
||||||
few things to consider:
|
|
||||||
|
|
||||||
- Base your Pull Request on the ``master`` branch if it fixes a bug,
|
- Please base your Pull Request on
|
||||||
or the ``upcoming/v4.X`` branch (if it exists at the moment of submitting the PR)
|
|
||||||
otherwise.
|
|
||||||
|
|
||||||
- We use `Pylint <https://www.pylint.org/>`__ for error and syntax checking of
|
- ``master``, which will be released with the next minor release, if it is
|
||||||
the source and `MyPy <https://github.com/python/mypy>`__ for type checking.
|
|
||||||
Beware that sometimes it might be better to disable a warning rather than
|
|
||||||
adapting the code to a tool's desires.
|
|
||||||
|
|
||||||
- The documentation source is located in the ``docs`` folder. The file
|
- a bug fix that does not require extensive testing,
|
||||||
``cli-options.rst`` is merely an RST-formatted copy of ``instaloader --help``
|
- an improvement to the documentation,
|
||||||
output, of which the source is in ``instaloader/__main__.py``.
|
|
||||||
|
- ``upcoming/v4.X``, if it is
|
||||||
|
|
||||||
|
- a new feature,
|
||||||
|
- a bug fix that does require thorough testing before being released to a
|
||||||
|
final version.
|
||||||
|
|
||||||
|
- All Pull Requests are analyzed by `Pylint <https://www.pylint.org/>`__ for
|
||||||
|
error and syntax checking of the source and
|
||||||
|
`Mypy <https://github.com/python/mypy>`__ for type checking. You can run them
|
||||||
|
locally with::
|
||||||
|
|
||||||
|
pylint instaloader
|
||||||
|
mypy -m instaloader
|
||||||
|
|
||||||
|
- Improvements to the documentation are very welcome. The documentation is
|
||||||
|
created with `Sphinx <https://www.sphinx-doc.org/en/2.0/>`__, version 2,
|
||||||
|
and can be build locally using::
|
||||||
|
|
||||||
|
make -C docs html
|
||||||
|
|
||||||
|
- Feel free to create an issue to make sure someone from the Instaloader team
|
||||||
|
agrees that the change might be an improvement, or if you want to discuss
|
||||||
|
your basic proposal, before working on a pull request.
|
||||||
|
|
||||||
Proposing Features
|
Proposing Features
|
||||||
------------------
|
------------------
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ See :ref:`install` for more options on how to install Instaloader.
|
|||||||
- allows **fine-grained customization** of filters and where to store
|
- allows **fine-grained customization** of filters and where to store
|
||||||
downloaded media,
|
downloaded media,
|
||||||
|
|
||||||
|
- automatically **resumes previously-interrupted** download iterations,
|
||||||
|
|
||||||
- is free `open source <https://github.com/instaloader/instaloader>`__
|
- is free `open source <https://github.com/instaloader/instaloader>`__
|
||||||
software written in Python.
|
software written in Python.
|
||||||
|
|
||||||
|
|||||||
+10
-7
@@ -5,27 +5,27 @@ Install Instaloader
|
|||||||
|
|
||||||
.. highlight:: none
|
.. highlight:: none
|
||||||
|
|
||||||
**To install Instaloader**,
|
To **install Instaloader**,
|
||||||
|
|
||||||
#. Ensure that you have `Python <https://www.python.org/>`__, at least
|
#. Ensure that you have `Python <https://www.python.org/>`__, at least
|
||||||
version 3.5 and `pip <https://pypi.python.org/pypi/pip>`__
|
version 3.6, and `pip <https://pypi.python.org/pypi/pip>`__
|
||||||
installed
|
installed.
|
||||||
|
|
||||||
#. Then, install Instaloader using::
|
#. Then, install Instaloader using::
|
||||||
|
|
||||||
pip3 install instaloader
|
pip3 install instaloader
|
||||||
|
|
||||||
**To upgrade Instaloader**, do::
|
To **upgrade Instaloader** to its current version, do::
|
||||||
|
|
||||||
pip3 install --upgrade instaloader
|
pip3 install --upgrade instaloader
|
||||||
|
|
||||||
|
|
||||||
**Alternative methods for installing Instaloader:**
|
**Alternative methods** for installing Instaloader:
|
||||||
|
|
||||||
- If you do not want to use pip, even though it is highly recommended,
|
- If you do not want to use pip, even though it is highly recommended,
|
||||||
and prefer to **install Instaloader manually**,
|
and prefer to **install Instaloader manually**,
|
||||||
`Download the Source <https://github.com/instaloader/instaloader/releases/latest>`__,
|
`Download the Source <https://github.com/instaloader/instaloader/releases/latest>`__,
|
||||||
extract the Zip or Tarball and execute ``instaloader.py`` from there.
|
extract the Zip or Tarball and run ``setup.py`` from there.
|
||||||
|
|
||||||
- On **Arch Linux**, you may install Instaloader using the
|
- On **Arch Linux**, you may install Instaloader using the
|
||||||
`Instaloader AUR package <https://aur.archlinux.org/packages/instaloader/>`__.
|
`Instaloader AUR package <https://aur.archlinux.org/packages/instaloader/>`__.
|
||||||
@@ -33,6 +33,9 @@ Install Instaloader
|
|||||||
- On **Windows 10**, you may download the standalone executable from the
|
- On **Windows 10**, you may download the standalone executable from the
|
||||||
`current release page <https://github.com/instaloader/instaloader/releases/latest>`__.
|
`current release page <https://github.com/instaloader/instaloader/releases/latest>`__.
|
||||||
|
|
||||||
- To test the most current pre-release or development version of Instaloader::
|
- On **Android**, you can use Instaloader with `Termux <https://play.google.com/store/apps/details?id=com.termux>`__
|
||||||
|
after typing ``pkg install python`` and ``pip3 install instaloader``.
|
||||||
|
|
||||||
|
- To test the most current **pre-release** version of Instaloader::
|
||||||
|
|
||||||
pip3 install --pre instaloader
|
pip3 install --pre instaloader
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
Exceptions
|
||||||
|
^^^^^^^^^^
|
||||||
|
|
||||||
|
.. module:: instaloader
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
.. highlight:: python
|
||||||
|
|
||||||
|
.. currentmodule:: instaloader.exceptions
|
||||||
|
|
||||||
|
.. autoexception:: InstaloaderException
|
||||||
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
.. autoexception:: ConnectionException
|
||||||
|
|
||||||
|
.. currentmodule:: instaloader
|
||||||
|
|
||||||
|
.. autoexception:: QueryReturnedBadRequestException
|
||||||
|
|
||||||
|
.. autoexception:: QueryReturnedForbiddenException
|
||||||
|
|
||||||
|
.. autoexception:: ProfileNotExistsException
|
||||||
|
|
||||||
|
.. autoexception:: ProfileHasNoPicsException
|
||||||
|
|
||||||
|
.. autoexception:: PrivateProfileNotFollowedException
|
||||||
|
|
||||||
|
.. autoexception:: LoginRequiredException
|
||||||
|
|
||||||
|
.. autoexception:: TwoFactorAuthRequiredException
|
||||||
|
|
||||||
|
.. versionadded:: 4.2
|
||||||
|
|
||||||
|
.. autoexception:: InvalidArgumentException
|
||||||
|
|
||||||
|
.. autoexception:: BadResponseException
|
||||||
|
|
||||||
|
.. autoexception:: BadCredentialsException
|
||||||
|
|
||||||
|
.. autoexception:: PostChangedException
|
||||||
|
|
||||||
|
.. autoexception:: QueryReturnedNotFoundException
|
||||||
|
|
||||||
|
.. versionchanged:: 4.3
|
||||||
|
QueryReturnedNotFoundException now inherits ConnectionException
|
||||||
|
to retry on 404 errors.
|
||||||
|
|
||||||
|
|
||||||
|
.. autoexception:: TooManyRequestsException
|
||||||
|
|
||||||
|
.. autoexception:: AbortDownloadException
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
``Instaloader`` (Main Class)
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
.. module:: instaloader
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
.. highlight:: python
|
||||||
|
|
||||||
|
.. autoclass:: Instaloader
|
||||||
|
:no-show-inheritance:
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
``InstaloaderContext`` (Low-level functions)
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
.. module:: instaloader
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
.. highlight:: python
|
||||||
|
|
||||||
|
.. contents::
|
||||||
|
:backlinks: none
|
||||||
|
|
||||||
|
``InstaloaderContext``
|
||||||
|
""""""""""""""""""""""
|
||||||
|
|
||||||
|
.. autoclass:: InstaloaderContext
|
||||||
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
``RateController``
|
||||||
|
""""""""""""""""""
|
||||||
|
|
||||||
|
.. autoclass:: RateController
|
||||||
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
.. versionadded:: 4.5
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
Resumable Iterations
|
||||||
|
^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
.. module:: instaloader
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
.. highlight:: python
|
||||||
|
|
||||||
|
.. contents::
|
||||||
|
:backlinks: none
|
||||||
|
|
||||||
|
For many download targets, Instaloader is able to resume a
|
||||||
|
previously-interrupted iteration. It provides an interruptible
|
||||||
|
Iterator :class:`NodeIterator` and a context manager
|
||||||
|
:func:`resumable_iteration`, which we both present here.
|
||||||
|
|
||||||
|
.. versionadded:: 4.5
|
||||||
|
|
||||||
|
``NodeIterator``
|
||||||
|
""""""""""""""""
|
||||||
|
|
||||||
|
.. autoclass:: NodeIterator
|
||||||
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
.. autoclass:: FrozenNodeIterator
|
||||||
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
``resumable_iteration``
|
||||||
|
"""""""""""""""""""""""
|
||||||
|
|
||||||
|
.. autofunction:: resumable_iteration
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
Instagram Structures
|
||||||
|
^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
.. module:: instaloader
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
.. highlight:: python
|
||||||
|
|
||||||
|
.. contents::
|
||||||
|
:backlinks: none
|
||||||
|
|
||||||
|
Posts
|
||||||
|
"""""
|
||||||
|
|
||||||
|
.. autoclass:: Post
|
||||||
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
Additionally, the following trivial structures are defined:
|
||||||
|
|
||||||
|
.. autoclass:: PostSidecarNode
|
||||||
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
.. autoclass:: PostComment
|
||||||
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
.. autoclass:: PostCommentAnswer
|
||||||
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
.. autoclass:: PostLocation
|
||||||
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
User Stories
|
||||||
|
""""""""""""
|
||||||
|
|
||||||
|
.. autoclass:: Story
|
||||||
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
.. autoclass:: StoryItem
|
||||||
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
Highlights
|
||||||
|
""""""""""
|
||||||
|
|
||||||
|
.. autoclass:: Highlight
|
||||||
|
:no-show-inheritance:
|
||||||
|
:inherited-members:
|
||||||
|
|
||||||
|
Bases: :class:`Story`
|
||||||
|
|
||||||
|
.. versionadded:: 4.1
|
||||||
|
|
||||||
|
Profiles
|
||||||
|
""""""""
|
||||||
|
|
||||||
|
.. autoclass:: Profile
|
||||||
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
Hashtags
|
||||||
|
""""""""
|
||||||
|
|
||||||
|
.. autoclass:: Hashtag
|
||||||
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
.. versionadded:: 4.4
|
||||||
|
|
||||||
|
TopSearchResults
|
||||||
|
""""""""""""""""
|
||||||
|
|
||||||
|
.. autoclass:: TopSearchResults
|
||||||
|
:no-show-inheritance:
|
||||||
|
|
||||||
|
.. versionadded:: 4.3
|
||||||
|
|
||||||
|
Loading and Saving
|
||||||
|
""""""""""""""""""
|
||||||
|
|
||||||
|
:class:`Post`, :class:`StoryItem`, :class:`Profile`, :class:`Hashtag` and
|
||||||
|
:class:`FrozenNodeIterator` can be saved and loaded to/from JSON files.
|
||||||
|
|
||||||
|
.. autofunction:: load_structure_from_file
|
||||||
|
|
||||||
|
.. autofunction:: save_structure_to_file
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
requests
|
requests
|
||||||
sphinx!=1.7.7
|
sphinx~=2.3
|
||||||
|
|||||||
+29
-22
@@ -5,40 +5,48 @@ Troubleshooting
|
|||||||
|
|
||||||
.. highlight:: python
|
.. highlight:: python
|
||||||
|
|
||||||
429 - Too Many Requests
|
429 Too Many Requests
|
||||||
-----------------------
|
---------------------
|
||||||
|
|
||||||
Instaloader has a logic to keep track of its requests to Instagram and to obey
|
Instaloader has a logic to keep track of its requests to Instagram and to obey
|
||||||
their rate limits. Since they are nowhere documented, we try them out
|
their rate limits. The rate controller assumes that
|
||||||
experimentally. We have a daily cron job running to confirm that Instaloader
|
|
||||||
still stays within the rate limits. Nevertheless, the rate control logic assumes
|
|
||||||
that
|
|
||||||
|
|
||||||
- at one time, Instaloader is the only application that consumes requests. I.e.
|
- at one time, Instaloader is the only application that consumes requests, i.e.
|
||||||
neither the Instagram browser interface, nor a mobile app, nor another
|
neither the Instagram browser interface, nor a mobile app, nor another
|
||||||
Instaloader instance is running in parallel,
|
Instaloader instance is running in parallel, and
|
||||||
|
|
||||||
- no requests had been consumed when Instaloader starts.
|
- no requests had been consumed when Instaloader starts.
|
||||||
|
|
||||||
The latter one implies that restarting or reinstantiating Instaloader often
|
The latter one implies that restarting or reinstantiating Instaloader often
|
||||||
within short time is prone to cause a 429. When a request is denied with a 429,
|
within short time is prone to cause a 429.
|
||||||
|
|
||||||
|
Since the behavior of the rate controller might change between different
|
||||||
|
versions of Instaloader, make sure to use the current version of Instaloader,
|
||||||
|
especially when encountering many 429 errors.
|
||||||
|
|
||||||
|
If a request is denied with a 429,
|
||||||
Instaloader retries the request as soon as the temporary ban is assumed to be
|
Instaloader retries the request as soon as the temporary ban is assumed to be
|
||||||
expired. In case the retry continuously fails for some reason, which should not
|
expired. In case the retry continuously fails for some reason, which should not
|
||||||
happen in normal conditions, consider adjusting the
|
happen under normal conditions, consider adjusting the
|
||||||
:option:`--max-connection-attempts` option.
|
:option:`--max-connection-attempts` option.
|
||||||
|
|
||||||
There have been observations that services, that in their nature offer
|
There have been observations that services, that in their nature offer
|
||||||
promiscious 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,
|
||||||
logged-in accesses (see :option:`--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
|
||||||
|
:class:`instaloader.RateController`.
|
||||||
|
|
||||||
Too many queries in the last time
|
Too many queries in the last time
|
||||||
---------------------------------
|
---------------------------------
|
||||||
|
|
||||||
**"Too many queries in the last time"** is not an error. It is a notice that the
|
**"Too many queries in the last time"** is not an error. It is a notice that the
|
||||||
rate limit has almost been reached, according to Instaloader's own rate
|
rate limit has almost been reached, according to Instaloader's own rate
|
||||||
accounting mechanism. We regularly adjust this mechanism to match Instagram's
|
accounting mechanism.
|
||||||
current rate limiting.
|
|
||||||
|
Instaloader allows to adjust the rate controlling behavior by overriding
|
||||||
|
:class:`instaloader.RateController`.
|
||||||
|
|
||||||
Private but not followed
|
Private but not followed
|
||||||
------------------------
|
------------------------
|
||||||
@@ -46,7 +54,7 @@ Private but not followed
|
|||||||
You have to follow a private account to access most of its associated
|
You have to follow a private account to access most of its associated
|
||||||
information.
|
information.
|
||||||
|
|
||||||
Login Error
|
Login error
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
Instaloader's login *should* work fine, both with and without
|
Instaloader's login *should* work fine, both with and without
|
||||||
@@ -55,13 +63,12 @@ issued when Instagram suspects authentication activity on your account, by
|
|||||||
pointing the user to an URL to be opened in a browser.
|
pointing the user to an URL to be opened in a browser.
|
||||||
|
|
||||||
Nevertheless, in :issue:`92` and :issue:`615` users reported problems with
|
Nevertheless, in :issue:`92` and :issue:`615` users reported problems with
|
||||||
logging in. We recommend to always keep the sessionfile which Instaloader
|
logging in. We recommend to always keep the session file which Instaloader
|
||||||
creates when using :option:`--login`. If a sessionfile is present,
|
creates when using :option:`--login`. If a session file is present,
|
||||||
:option:`--login` does not make make use of the failure-prone login procedure.
|
:option:`--login` does not make use of the failure-prone login procedure.
|
||||||
Also, sessionfiles usually do not expire and can be copied between different
|
Also, session files usually do not expire.
|
||||||
computers without any problems.
|
|
||||||
|
|
||||||
If you do not have a sessionfile present, you may use the following script
|
If you do not have a session file present, you may use the following script
|
||||||
(:example:`615_import_firefox_session.py`) to workaround login problems by
|
(:example:`615_import_firefox_session.py`) to workaround login problems by
|
||||||
importing the session cookies from Firefox and bypassing Instaloader's login and
|
importing the session cookies from Firefox and bypassing Instaloader's login and
|
||||||
so still use Instaloader's logged-in functionality.
|
so still use Instaloader's logged-in functionality.
|
||||||
@@ -78,7 +85,7 @@ To use this script,
|
|||||||
|
|
||||||
#. Then, ``instaloader -l USERNAME`` should work fine.
|
#. Then, ``instaloader -l USERNAME`` should work fine.
|
||||||
|
|
||||||
This script also supports specifying a cookiefile path, which may be useful if
|
This script also supports specifying a cookie file path, which may be useful if
|
||||||
you use multiple Firefox profiles or if your operating system has the directory
|
you use multiple Firefox profiles or if your operating system has the directory
|
||||||
structure differently set up. Also, you can specify an alternative sessionfile
|
structure differently set up. Also, you can specify an alternative session file
|
||||||
path.
|
path.
|
||||||
|
|||||||
@@ -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.4.5'
|
__version__ = '4.7'
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -14,6 +14,7 @@ else:
|
|||||||
|
|
||||||
from .exceptions import *
|
from .exceptions import *
|
||||||
from .instaloader import Instaloader
|
from .instaloader import Instaloader
|
||||||
from .instaloadercontext import InstaloaderContext
|
from .instaloadercontext import InstaloaderContext, RateController
|
||||||
|
from .nodeiterator import NodeIterator, FrozenNodeIterator, resumable_iteration
|
||||||
from .structures import (Hashtag, Highlight, Post, PostSidecarNode, PostComment, PostCommentAnswer, PostLocation,
|
from .structures import (Hashtag, Highlight, Post, PostSidecarNode, PostComment, PostCommentAnswer, PostLocation,
|
||||||
Profile, Story, StoryItem, TopSearchResults, load_structure_from_file, save_structure_to_file)
|
Profile, Story, StoryItem, TopSearchResults, load_structure_from_file, save_structure_to_file)
|
||||||
|
|||||||
+41
-19
@@ -5,12 +5,12 @@ import datetime
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from argparse import ArgumentParser, SUPPRESS
|
from argparse import ArgumentParser, ArgumentTypeError, SUPPRESS
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from . import (Instaloader, InstaloaderException, InvalidArgumentException, Post, Profile, ProfileNotExistsException,
|
from . import (AbortDownloadException, BadCredentialsException, Instaloader, InstaloaderException,
|
||||||
StoryItem, __version__, load_structure_from_file, TwoFactorAuthRequiredException,
|
InvalidArgumentException, Post, Profile, ProfileNotExistsException, StoryItem,
|
||||||
BadCredentialsException)
|
TwoFactorAuthRequiredException, __version__, load_structure_from_file)
|
||||||
from .instaloader import get_default_session_filename
|
from .instaloader import get_default_session_filename
|
||||||
from .instaloadercontext import default_user_agent
|
from .instaloadercontext import default_user_agent
|
||||||
|
|
||||||
@@ -27,6 +27,14 @@ def usage_string():
|
|||||||
{0} --help""".format(argv0, len(argv0), '')
|
{0} --help""".format(argv0, len(argv0), '')
|
||||||
|
|
||||||
|
|
||||||
|
def http_status_code_list(code_list_str: str) -> List[int]:
|
||||||
|
codes = [int(s) for s in code_list_str.split(',')]
|
||||||
|
for code in codes:
|
||||||
|
if not 100 <= code <= 599:
|
||||||
|
raise ArgumentTypeError("Invalid HTTP status code: {}".format(code))
|
||||||
|
return codes
|
||||||
|
|
||||||
|
|
||||||
def filterstr_to_filterfunc(filter_str: str, item_type: type):
|
def filterstr_to_filterfunc(filter_str: str, item_type: type):
|
||||||
"""Takes an --post-filter=... or --storyitem-filter=... filter
|
"""Takes an --post-filter=... or --storyitem-filter=... filter
|
||||||
specification and makes a filter_func Callable out of it."""
|
specification and makes a filter_func Callable out of it."""
|
||||||
@@ -199,7 +207,7 @@ def _main(instaloader: Instaloader, targetlist: List[str],
|
|||||||
instaloader.context.log("Downloading {} profiles: {}".format(len(profiles),
|
instaloader.context.log("Downloading {} profiles: {}".format(len(profiles),
|
||||||
' '.join([p.username for p in profiles])))
|
' '.join([p.username for p in profiles])))
|
||||||
if profiles and (download_profile_pic or download_posts) and not instaloader.context.is_logged_in:
|
if profiles and (download_profile_pic or download_posts) and not instaloader.context.is_logged_in:
|
||||||
instaloader.context.error("Warning: Use --login to download higher-quality versions of pictures.")
|
instaloader.context.log("Hint: Use --login to download higher-quality versions of pictures.")
|
||||||
instaloader.download_profiles(profiles,
|
instaloader.download_profiles(profiles,
|
||||||
download_profile_pic, download_posts, download_tagged, download_igtv,
|
download_profile_pic, download_posts, download_tagged, download_igtv,
|
||||||
download_highlights, download_stories,
|
download_highlights, download_stories,
|
||||||
@@ -213,6 +221,8 @@ def _main(instaloader: Instaloader, targetlist: List[str],
|
|||||||
fast_update=fast_update, post_filter=post_filter)
|
fast_update=fast_update, post_filter=post_filter)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\nInterrupted by user.", file=sys.stderr)
|
print("\nInterrupted by user.", file=sys.stderr)
|
||||||
|
except AbortDownloadException as exc:
|
||||||
|
print("\nDownload aborted: {}.".format(exc), file=sys.stderr)
|
||||||
# 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)
|
||||||
@@ -222,14 +232,13 @@ def _main(instaloader: Instaloader, targetlist: List[str],
|
|||||||
# Instaloader did at least save a session file
|
# Instaloader did at least save a session file
|
||||||
instaloader.context.log("No targets were specified, thus nothing has been downloaded.")
|
instaloader.context.log("No targets were specified, thus nothing has been downloaded.")
|
||||||
else:
|
else:
|
||||||
# Instloader did not do anything
|
# Instaloader did not do anything
|
||||||
instaloader.context.log("usage:" + usage_string())
|
instaloader.context.log("usage:" + usage_string())
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = ArgumentParser(description=__doc__, add_help=False, usage=usage_string(),
|
parser = ArgumentParser(description=__doc__, add_help=False, usage=usage_string(),
|
||||||
epilog="Report issues at https://github.com/instaloader/instaloader/issues. "
|
epilog="The complete documentation can be found at "
|
||||||
"The complete documentation can be found at "
|
|
||||||
"https://instaloader.github.io/.",
|
"https://instaloader.github.io/.",
|
||||||
fromfile_prefix_chars='+')
|
fromfile_prefix_chars='+')
|
||||||
|
|
||||||
@@ -268,6 +277,8 @@ def main():
|
|||||||
help="Do not download regular posts.")
|
help="Do not download regular posts.")
|
||||||
g_prof.add_argument('--no-profile-pic', action='store_true',
|
g_prof.add_argument('--no-profile-pic', action='store_true',
|
||||||
help='Do not download profile picture.')
|
help='Do not download profile picture.')
|
||||||
|
g_post.add_argument('--slide', action='store',
|
||||||
|
help='Set what image/interval of a sidecar you want to download.')
|
||||||
g_post.add_argument('--no-pictures', action='store_true',
|
g_post.add_argument('--no-pictures', action='store_true',
|
||||||
help='Do not download post pictures. Cannot be used together with --fast-update. '
|
help='Do not download post pictures. Cannot be used together with --fast-update. '
|
||||||
'Implies --no-video-thumbnails, does not imply --no-videos.')
|
'Implies --no-video-thumbnails, does not imply --no-videos.')
|
||||||
@@ -351,6 +362,13 @@ def main():
|
|||||||
'--dirname-pattern. {profile} is replaced by the profile name,'
|
'--dirname-pattern. {profile} is replaced by the profile name,'
|
||||||
'{target} is replaced by the target you specified, i.e. either :feed'
|
'{target} is replaced by the target you specified, i.e. either :feed'
|
||||||
'#hashtag or the profile name. Defaults to \'{date_utc}_UTC\'')
|
'#hashtag or the profile name. Defaults to \'{date_utc}_UTC\'')
|
||||||
|
g_how.add_argument('--resume-prefix', metavar='PREFIX',
|
||||||
|
help='Prefix for filenames that are used to save the information to resume an interrupted '
|
||||||
|
'download.')
|
||||||
|
g_how.add_argument('--no-resume', action='store_true',
|
||||||
|
help='Do not resume a previously-aborted download iteration, and do not save such information '
|
||||||
|
'when interrupted.')
|
||||||
|
g_how.add_argument('--use-aged-resume-files', action='store_true', help=SUPPRESS)
|
||||||
g_how.add_argument('--user-agent',
|
g_how.add_argument('--user-agent',
|
||||||
help='User Agent to use for HTTP requests. Defaults to \'{}\'.'.format(default_user_agent()))
|
help='User Agent to use for HTTP requests. Defaults to \'{}\'.'.format(default_user_agent()))
|
||||||
g_how.add_argument('-S', '--no-sleep', action='store_true', help=SUPPRESS)
|
g_how.add_argument('-S', '--no-sleep', action='store_true', help=SUPPRESS)
|
||||||
@@ -358,12 +376,12 @@ def main():
|
|||||||
help='Maximum number of connection attempts until a request is aborted. Defaults to 3. If a '
|
help='Maximum number of connection attempts until a request is aborted. Defaults to 3. If a '
|
||||||
'connection fails, it can be manually skipped by hitting CTRL+C. Set this to 0 to retry '
|
'connection fails, it can be manually skipped by hitting CTRL+C. Set this to 0 to retry '
|
||||||
'infinitely.')
|
'infinitely.')
|
||||||
g_how.add_argument('--commit-mode', action='store_true',
|
g_how.add_argument('--commit-mode', action='store_true', help=SUPPRESS)
|
||||||
help='Tries to ensure downloaded images avoid corruption in case of unexpected interruption. '
|
g_how.add_argument('--request-timeout', metavar='N', type=float, default=300.0,
|
||||||
'If the last picture is corrupted, Instaloader will fix the picture the next time it is run. '
|
help='Seconds to wait before timing out a connection request. Defaults to 300.')
|
||||||
'Requires the JSON metadata to be saved.')
|
g_how.add_argument('--abort-on', type=http_status_code_list, metavar="STATUS_CODES",
|
||||||
g_how.add_argument('--request-timeout', metavar='N', type=float,
|
help='Comma-separated list of HTTP status codes that cause Instaloader to abort, bypassing all '
|
||||||
help='seconds to wait before timing out a connection request')
|
'retry logic.')
|
||||||
|
|
||||||
g_misc = parser.add_argument_group('Miscellaneous Options')
|
g_misc = parser.add_argument_group('Miscellaneous Options')
|
||||||
g_misc.add_argument('-q', '--quiet', action='store_true',
|
g_misc.add_argument('-q', '--quiet', action='store_true',
|
||||||
@@ -397,6 +415,10 @@ def main():
|
|||||||
raise SystemExit("--no-captions and --post-metadata-txt or --storyitem-metadata-txt given; "
|
raise SystemExit("--no-captions and --post-metadata-txt or --storyitem-metadata-txt given; "
|
||||||
"That contradicts.")
|
"That contradicts.")
|
||||||
|
|
||||||
|
if args.no_resume and args.resume_prefix:
|
||||||
|
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
|
||||||
|
|
||||||
if args.no_pictures and args.fast_update:
|
if args.no_pictures and args.fast_update:
|
||||||
raise SystemExit('--no-pictures and --fast-update cannot be used together.')
|
raise SystemExit('--no-pictures and --fast-update cannot be used together.')
|
||||||
|
|
||||||
@@ -405,9 +427,6 @@ def main():
|
|||||||
download_posts = not (args.no_posts or args.stories_only or args.profile_pic_only)
|
download_posts = not (args.no_posts or args.stories_only or args.profile_pic_only)
|
||||||
download_stories = args.stories or args.stories_only
|
download_stories = args.stories or args.stories_only
|
||||||
|
|
||||||
if args.commit_mode and args.no_metadata_json:
|
|
||||||
raise SystemExit('--commit-mode requires JSON metadata to be saved.')
|
|
||||||
|
|
||||||
loader = Instaloader(sleep=not args.no_sleep, quiet=args.quiet, user_agent=args.user_agent,
|
loader = Instaloader(sleep=not args.no_sleep, quiet=args.quiet, user_agent=args.user_agent,
|
||||||
dirname_pattern=args.dirname_pattern, filename_pattern=args.filename_pattern,
|
dirname_pattern=args.dirname_pattern, filename_pattern=args.filename_pattern,
|
||||||
download_pictures=not args.no_pictures,
|
download_pictures=not args.no_pictures,
|
||||||
@@ -419,7 +438,10 @@ def main():
|
|||||||
storyitem_metadata_txt_pattern=storyitem_metadata_txt_pattern,
|
storyitem_metadata_txt_pattern=storyitem_metadata_txt_pattern,
|
||||||
max_connection_attempts=args.max_connection_attempts,
|
max_connection_attempts=args.max_connection_attempts,
|
||||||
request_timeout=args.request_timeout,
|
request_timeout=args.request_timeout,
|
||||||
commit_mode=args.commit_mode)
|
resume_prefix=resume_prefix,
|
||||||
|
check_resume_bbd=not args.use_aged_resume_files,
|
||||||
|
slide=args.slide,
|
||||||
|
fatal_status_codes=args.abort_on)
|
||||||
_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,
|
||||||
@@ -437,7 +459,7 @@ def main():
|
|||||||
storyitem_filter_str=args.storyitem_filter)
|
storyitem_filter_str=args.storyitem_filter)
|
||||||
loader.close()
|
loader.close()
|
||||||
except InstaloaderException as err:
|
except InstaloaderException as err:
|
||||||
raise SystemExit("Fatal error: %s" % err)
|
raise SystemExit("Fatal error: %s" % err) from err
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -64,3 +64,15 @@ class QueryReturnedNotFoundException(ConnectionException):
|
|||||||
|
|
||||||
class TooManyRequestsException(ConnectionException):
|
class TooManyRequestsException(ConnectionException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AbortDownloadException(Exception):
|
||||||
|
"""
|
||||||
|
Exception that is not catched in the error catchers inside the download loop and so aborts the
|
||||||
|
download loop.
|
||||||
|
|
||||||
|
This exception is not a subclass of ``InstaloaderException``.
|
||||||
|
|
||||||
|
.. versionadded:: 4.7
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|||||||
+300
-156
@@ -1,6 +1,5 @@
|
|||||||
import getpass
|
import getpass
|
||||||
import json
|
import json
|
||||||
import lzma
|
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
@@ -14,15 +13,17 @@ from functools import wraps
|
|||||||
from hashlib import md5
|
from hashlib import md5
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Iterator, List, Optional, Set, Union
|
from typing import Any, Callable, IO, Iterator, List, Optional, Set, Union, cast
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import urllib3 # type: ignore
|
import urllib3 # type: ignore
|
||||||
|
|
||||||
from .exceptions import *
|
from .exceptions import *
|
||||||
from .instaloadercontext import InstaloaderContext
|
from .instaloadercontext import InstaloaderContext, RateController
|
||||||
|
from .nodeiterator import NodeIterator, resumable_iteration
|
||||||
from .structures import (Hashtag, Highlight, JsonExportable, Post, PostLocation, Profile, Story, StoryItem,
|
from .structures import (Hashtag, Highlight, JsonExportable, Post, PostLocation, Profile, Story, StoryItem,
|
||||||
save_structure_to_file, load_structure_from_file)
|
load_structure_from_file, save_structure_to_file, PostSidecarNode)
|
||||||
|
|
||||||
|
|
||||||
def get_default_session_filename(username: str) -> str:
|
def get_default_session_filename(username: str) -> str:
|
||||||
@@ -101,6 +102,8 @@ class _ArbitraryItemFormatter(string.Formatter):
|
|||||||
|
|
||||||
def get_value(self, key, args, kwargs):
|
def get_value(self, key, args, kwargs):
|
||||||
"""Override to substitute {ATTRIBUTE} by attributes of our _item."""
|
"""Override to substitute {ATTRIBUTE} by attributes of our _item."""
|
||||||
|
if key == 'filename' and isinstance(self._item, (Post, StoryItem, PostSidecarNode)):
|
||||||
|
return "{filename}"
|
||||||
if hasattr(self._item, key):
|
if hasattr(self._item, key):
|
||||||
return getattr(self._item, key)
|
return getattr(self._item, key)
|
||||||
return super().get_value(key, args, kwargs)
|
return super().get_value(key, args, kwargs)
|
||||||
@@ -153,8 +156,12 @@ class Instaloader:
|
|||||||
txt file.
|
txt file.
|
||||||
:param storyitem_metadata_txt_pattern: :option:`--storyitem-metadata-txt`, default is empty (=none)
|
:param storyitem_metadata_txt_pattern: :option:`--storyitem-metadata-txt`, default is empty (=none)
|
||||||
:param max_connection_attempts: :option:`--max-connection-attempts`
|
:param max_connection_attempts: :option:`--max-connection-attempts`
|
||||||
:param commit_mode: :option:`--commit-mode`
|
|
||||||
:param request_timeout: :option:`--request-timeout`, set per-request timeout (seconds)
|
:param request_timeout: :option:`--request-timeout`, set per-request timeout (seconds)
|
||||||
|
:param rate_controller: Generator for a :class:`RateController` to override rate controlling behavior
|
||||||
|
:param resume_prefix: :option:`--resume-prefix`, or None for :option:`--no-resume`.
|
||||||
|
:param check_resume_bbd: Whether to check the date of expiry of resume files and reject them if expired.
|
||||||
|
:param slide: :option:`--slide`
|
||||||
|
:param fatal_status_codes: :option:`--abort-on`
|
||||||
|
|
||||||
.. attribute:: context
|
.. attribute:: context
|
||||||
|
|
||||||
@@ -170,17 +177,22 @@ class Instaloader:
|
|||||||
download_pictures=True,
|
download_pictures=True,
|
||||||
download_videos: bool = True,
|
download_videos: bool = True,
|
||||||
download_video_thumbnails: bool = True,
|
download_video_thumbnails: bool = True,
|
||||||
download_geotags: bool = True,
|
download_geotags: bool = False,
|
||||||
download_comments: bool = True,
|
download_comments: bool = False,
|
||||||
save_metadata: bool = True,
|
save_metadata: bool = True,
|
||||||
compress_json: bool = True,
|
compress_json: bool = True,
|
||||||
post_metadata_txt_pattern: str = None,
|
post_metadata_txt_pattern: str = None,
|
||||||
storyitem_metadata_txt_pattern: str = None,
|
storyitem_metadata_txt_pattern: str = None,
|
||||||
max_connection_attempts: int = 3,
|
max_connection_attempts: int = 3,
|
||||||
request_timeout: Optional[float] = None,
|
request_timeout: float = 300.0,
|
||||||
commit_mode: bool = False):
|
rate_controller: Optional[Callable[[InstaloaderContext], RateController]] = None,
|
||||||
|
resume_prefix: Optional[str] = "iterator",
|
||||||
|
check_resume_bbd: bool = True,
|
||||||
|
slide: Optional[str] = None,
|
||||||
|
fatal_status_codes: Optional[List[int]] = None):
|
||||||
|
|
||||||
self.context = InstaloaderContext(sleep, quiet, user_agent, max_connection_attempts, request_timeout)
|
self.context = InstaloaderContext(sleep, quiet, user_agent, max_connection_attempts,
|
||||||
|
request_timeout, rate_controller, fatal_status_codes)
|
||||||
|
|
||||||
# configuration parameters
|
# configuration parameters
|
||||||
self.dirname_pattern = dirname_pattern or "{target}"
|
self.dirname_pattern = dirname_pattern or "{target}"
|
||||||
@@ -196,12 +208,33 @@ class Instaloader:
|
|||||||
else post_metadata_txt_pattern
|
else post_metadata_txt_pattern
|
||||||
self.storyitem_metadata_txt_pattern = '' if storyitem_metadata_txt_pattern is None \
|
self.storyitem_metadata_txt_pattern = '' if storyitem_metadata_txt_pattern is None \
|
||||||
else storyitem_metadata_txt_pattern
|
else storyitem_metadata_txt_pattern
|
||||||
self.commit_mode = commit_mode
|
self.resume_prefix = resume_prefix
|
||||||
if self.commit_mode and not self.save_metadata:
|
self.check_resume_bbd = check_resume_bbd
|
||||||
raise InvalidArgumentException("Commit mode requires JSON metadata to be saved.")
|
|
||||||
|
|
||||||
# Used to keep state in commit mode
|
self.slide = slide or ""
|
||||||
self._committed = None # type: Optional[bool]
|
self.slide_start = 0
|
||||||
|
self.slide_end = -1
|
||||||
|
if self.slide != "":
|
||||||
|
splitted = self.slide.split('-')
|
||||||
|
if len(splitted) == 1:
|
||||||
|
if splitted[0] == 'last':
|
||||||
|
# download only last image of a sidecar
|
||||||
|
self.slide_start = -1
|
||||||
|
else:
|
||||||
|
if int(splitted[0]) > 0:
|
||||||
|
self.slide_start = self.slide_end = int(splitted[0])-1
|
||||||
|
else:
|
||||||
|
raise InvalidArgumentException("--slide parameter must be greater than 0.")
|
||||||
|
elif len(splitted) == 2:
|
||||||
|
if splitted[1] == 'last':
|
||||||
|
self.slide_start = int(splitted[0])-1
|
||||||
|
elif 0 < int(splitted[0]) < int(splitted[1]):
|
||||||
|
self.slide_start = int(splitted[0])-1
|
||||||
|
self.slide_end = int(splitted[1])-1
|
||||||
|
else:
|
||||||
|
raise InvalidArgumentException("Invalid data for --slide parameter.")
|
||||||
|
else:
|
||||||
|
raise InvalidArgumentException("Invalid data for --slide parameter.")
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def anonymous_copy(self):
|
def anonymous_copy(self):
|
||||||
@@ -223,7 +256,10 @@ class Instaloader:
|
|||||||
storyitem_metadata_txt_pattern=self.storyitem_metadata_txt_pattern,
|
storyitem_metadata_txt_pattern=self.storyitem_metadata_txt_pattern,
|
||||||
max_connection_attempts=self.context.max_connection_attempts,
|
max_connection_attempts=self.context.max_connection_attempts,
|
||||||
request_timeout=self.context.request_timeout,
|
request_timeout=self.context.request_timeout,
|
||||||
commit_mode=self.commit_mode)
|
resume_prefix=self.resume_prefix,
|
||||||
|
check_resume_bbd=self.check_resume_bbd,
|
||||||
|
slide=self.slide,
|
||||||
|
fatal_status_codes=self.context.fatal_status_codes)
|
||||||
yield new_loader
|
yield new_loader
|
||||||
self.context.error_log.extend(new_loader.context.error_log)
|
self.context.error_log.extend(new_loader.context.error_log)
|
||||||
new_loader.context.error_log = [] # avoid double-printing of errors
|
new_loader.context.error_log = [] # avoid double-printing of errors
|
||||||
@@ -249,15 +285,9 @@ class Instaloader:
|
|||||||
if filename_suffix is not None:
|
if filename_suffix is not None:
|
||||||
filename += '_' + filename_suffix
|
filename += '_' + filename_suffix
|
||||||
filename += '.' + file_extension
|
filename += '.' + file_extension
|
||||||
# A post is considered "commited" if the json file exists and is not malformed.
|
if os.path.isfile(filename):
|
||||||
if self.commit_mode:
|
self.context.log(filename + ' exists', end=' ', flush=True)
|
||||||
if self._committed and os.path.isfile(filename):
|
return False
|
||||||
self.context.log(filename + ' exists', end=' ', flush=True)
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
if os.path.isfile(filename):
|
|
||||||
self.context.log(filename + ' exists', end=' ', flush=True)
|
|
||||||
return False
|
|
||||||
self.context.get_and_write_raw(url, filename)
|
self.context.get_and_write_raw(url, filename)
|
||||||
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
|
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
|
||||||
return True
|
return True
|
||||||
@@ -305,18 +335,47 @@ class Instaloader:
|
|||||||
combined_answers.extend(y['answers'])
|
combined_answers.extend(y['answers'])
|
||||||
unique_comments_list[-1]['answers'] = get_unique_comments(combined_answers)
|
unique_comments_list[-1]['answers'] = get_unique_comments(combined_answers)
|
||||||
return unique_comments_list
|
return unique_comments_list
|
||||||
|
|
||||||
|
def get_new_comments(new_comments, start):
|
||||||
|
for idx, comment in enumerate(new_comments, start=start+1):
|
||||||
|
if idx % 250 == 0:
|
||||||
|
self.context.log('{}'.format(idx), end='…', flush=True)
|
||||||
|
yield comment
|
||||||
|
|
||||||
|
def save_comments(extended_comments):
|
||||||
|
unique_comments = get_unique_comments(extended_comments, combine_answers=True)
|
||||||
|
answer_ids = set(int(answer['id']) for comment in unique_comments for answer in comment.get('answers', []))
|
||||||
|
with open(filename, 'w') as file:
|
||||||
|
file.write(json.dumps(list(filter(lambda t: int(t['id']) not in answer_ids, unique_comments)),
|
||||||
|
indent=4))
|
||||||
|
|
||||||
|
base_filename = filename
|
||||||
filename += '_comments.json'
|
filename += '_comments.json'
|
||||||
try:
|
try:
|
||||||
with open(filename) as fp:
|
with open(filename) as fp:
|
||||||
comments = json.load(fp)
|
comments = json.load(fp)
|
||||||
except (FileNotFoundError, json.decoder.JSONDecodeError):
|
except (FileNotFoundError, json.decoder.JSONDecodeError):
|
||||||
comments = list()
|
comments = list()
|
||||||
comments.extend(_postcomment_asdict(comment) for comment in post.get_comments())
|
|
||||||
|
comments_iterator = post.get_comments()
|
||||||
|
try:
|
||||||
|
with resumable_iteration(
|
||||||
|
context=self.context,
|
||||||
|
iterator=comments_iterator,
|
||||||
|
load=load_structure_from_file,
|
||||||
|
save=save_structure_to_file,
|
||||||
|
format_path=lambda magic: "{}_{}_{}.json.xz".format(base_filename, self.resume_prefix, magic),
|
||||||
|
check_bbd=self.check_resume_bbd,
|
||||||
|
enabled=self.resume_prefix is not None
|
||||||
|
) as (_is_resuming, start_index):
|
||||||
|
comments.extend(_postcomment_asdict(comment)
|
||||||
|
for comment in get_new_comments(comments_iterator, start_index))
|
||||||
|
except (KeyboardInterrupt, AbortDownloadException):
|
||||||
|
if comments:
|
||||||
|
save_comments(comments)
|
||||||
|
raise
|
||||||
if comments:
|
if comments:
|
||||||
comments = get_unique_comments(comments, combine_answers=True)
|
save_comments(comments)
|
||||||
answer_ids = set(int(answer['id']) for comment in comments for answer in comment.get('answers', []))
|
|
||||||
with open(filename, 'w') as file:
|
|
||||||
file.write(json.dumps(list(filter(lambda t: int(t['id']) not in answer_ids, comments)), indent=4))
|
|
||||||
self.context.log('comments', end=' ', flush=True)
|
self.context.log('comments', end=' ', flush=True)
|
||||||
|
|
||||||
def save_caption(self, filename: str, mtime: datetime, caption: str) -> None:
|
def save_caption(self, filename: str, mtime: datetime, caption: str) -> None:
|
||||||
@@ -355,7 +414,8 @@ class Instaloader:
|
|||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
self.context.log('txt', end=' ', flush=True)
|
self.context.log('txt', end=' ', flush=True)
|
||||||
with open(filename, 'wb') as text_file:
|
with open(filename, 'wb') as text_file:
|
||||||
shutil.copyfileobj(BytesIO(bcaption), text_file)
|
with BytesIO(bcaption) as bio:
|
||||||
|
shutil.copyfileobj(cast(IO, bio), text_file)
|
||||||
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
|
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
|
||||||
|
|
||||||
def save_location(self, filename: str, location: PostLocation, mtime: datetime) -> None:
|
def save_location(self, filename: str, location: PostLocation, mtime: datetime) -> None:
|
||||||
@@ -365,10 +425,29 @@ class Instaloader:
|
|||||||
"https://maps.google.com/maps?q={0},{1}&ll={0},{1}\n".format(location.lat,
|
"https://maps.google.com/maps?q={0},{1}&ll={0},{1}\n".format(location.lat,
|
||||||
location.lng))
|
location.lng))
|
||||||
with open(filename, 'wb') as text_file:
|
with open(filename, 'wb') as text_file:
|
||||||
shutil.copyfileobj(BytesIO(location_string.encode()), text_file)
|
with BytesIO(location_string.encode()) as bio:
|
||||||
|
shutil.copyfileobj(cast(IO, bio), text_file)
|
||||||
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
|
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
|
||||||
self.context.log('geo', end=' ', flush=True)
|
self.context.log('geo', end=' ', flush=True)
|
||||||
|
|
||||||
|
def format_filename_within_target_path(self,
|
||||||
|
target: Union[str, Path],
|
||||||
|
owner_profile: Optional[Profile],
|
||||||
|
identifier: str,
|
||||||
|
name_suffix: str,
|
||||||
|
extension: str):
|
||||||
|
"""Returns a filename within the target path.
|
||||||
|
|
||||||
|
.. versionadded:: 4.5"""
|
||||||
|
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
||||||
|
format_string_contains_key(self.dirname_pattern, 'target'))):
|
||||||
|
profile_str = owner_profile.username.lower() if owner_profile is not None else target
|
||||||
|
return os.path.join(self.dirname_pattern.format(profile=profile_str, target=target),
|
||||||
|
'{0}_{1}.{2}'.format(identifier, name_suffix, extension))
|
||||||
|
else:
|
||||||
|
return os.path.join(self.dirname_pattern.format(),
|
||||||
|
'{0}_{1}_{2}.{3}'.format(target, identifier, name_suffix, extension))
|
||||||
|
|
||||||
@_retry_on_connection_error
|
@_retry_on_connection_error
|
||||||
def download_title_pic(self, url: str, target: Union[str, Path], name_suffix: str, owner_profile: Optional[Profile],
|
def download_title_pic(self, url: str, target: Union[str, Path], name_suffix: str, owner_profile: Optional[Profile],
|
||||||
_attempt: int = 1) -> None:
|
_attempt: int = 1) -> None:
|
||||||
@@ -389,16 +468,7 @@ class Instaloader:
|
|||||||
else:
|
else:
|
||||||
pic_bytes = http_response.content
|
pic_bytes = http_response.content
|
||||||
pic_identifier = md5(pic_bytes).hexdigest()[:16]
|
pic_identifier = md5(pic_bytes).hexdigest()[:16]
|
||||||
pic_extension = 'jpg'
|
filename = self.format_filename_within_target_path(target, owner_profile, pic_identifier, name_suffix, 'jpg')
|
||||||
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
|
||||||
format_string_contains_key(self.dirname_pattern, 'target'))):
|
|
||||||
profile_str = owner_profile.username.lower() if owner_profile is not None else target
|
|
||||||
filename = os.path.join(self.dirname_pattern.format(profile=profile_str,
|
|
||||||
target=target),
|
|
||||||
'{0}_{1}.{2}'.format(pic_identifier, name_suffix, pic_extension))
|
|
||||||
else:
|
|
||||||
filename = os.path.join(self.dirname_pattern.format(),
|
|
||||||
'{0}_{1}_{2}.{3}'.format(target, pic_identifier, name_suffix, pic_extension))
|
|
||||||
content_length = http_response.headers.get('Content-Length', None)
|
content_length = http_response.headers.get('Content-Length', None)
|
||||||
if os.path.isfile(filename) and (not self.context.is_logged_in or
|
if os.path.isfile(filename) and (not self.context.is_logged_in or
|
||||||
(content_length is not None and
|
(content_length is not None and
|
||||||
@@ -485,7 +555,20 @@ class Instaloader:
|
|||||||
.. versionadded:: 4.2"""
|
.. versionadded:: 4.2"""
|
||||||
self.context.two_factor_login(two_factor_code)
|
self.context.two_factor_login(two_factor_code)
|
||||||
|
|
||||||
def format_filename(self, item: Union[Post, StoryItem], target: Optional[Union[str, Path]] = None):
|
@staticmethod
|
||||||
|
def __prepare_filename(filename_template: str, url: Callable[[], str]) -> str:
|
||||||
|
"""Replace filename token inside filename_template with url's filename and assure the directories exist.
|
||||||
|
|
||||||
|
.. versionadded:: 4.6"""
|
||||||
|
if "{filename}" in filename_template:
|
||||||
|
filename = filename_template.replace("{filename}",
|
||||||
|
os.path.splitext(os.path.basename(urlparse(url()).path))[0])
|
||||||
|
else:
|
||||||
|
filename = filename_template
|
||||||
|
os.makedirs(os.path.dirname(filename), exist_ok=True)
|
||||||
|
return filename
|
||||||
|
|
||||||
|
def format_filename(self, item: Union[Post, StoryItem, PostSidecarNode], target: Optional[Union[str, Path]] = None):
|
||||||
"""Format filename of a :class:`Post` or :class:`StoryItem` according to ``filename-pattern`` parameter.
|
"""Format filename of a :class:`Post` or :class:`StoryItem` according to ``filename-pattern`` parameter.
|
||||||
|
|
||||||
.. versionadded:: 4.1"""
|
.. versionadded:: 4.1"""
|
||||||
@@ -500,33 +583,78 @@ class Instaloader:
|
|||||||
:return: True if something was downloaded, False otherwise, i.e. file was already there
|
:return: True if something was downloaded, False otherwise, i.e. file was already there
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def _already_downloaded(path: str) -> bool:
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
self.context.log(path + ' exists', end=' ', flush=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _all_already_downloaded(path_base, is_videos_enumerated) -> bool:
|
||||||
|
if '{filename}' in self.filename_pattern:
|
||||||
|
# full URL needed to evaluate actual filename, cannot determine at
|
||||||
|
# this point if all sidecar nodes were already downloaded.
|
||||||
|
return False
|
||||||
|
for idx, is_video in is_videos_enumerated:
|
||||||
|
if self.download_pictures and (not is_video or self.download_video_thumbnails):
|
||||||
|
if not _already_downloaded("{0}_{1}.jpg".format(path_base, idx)):
|
||||||
|
return False
|
||||||
|
if is_video and self.download_videos:
|
||||||
|
if not _already_downloaded("{0}_{1}.mp4".format(path_base, idx)):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
dirname = _PostPathFormatter(post).format(self.dirname_pattern, target=target)
|
dirname = _PostPathFormatter(post).format(self.dirname_pattern, target=target)
|
||||||
filename = os.path.join(dirname, self.format_filename(post, target=target))
|
filename_template = os.path.join(dirname, self.format_filename(post, target=target))
|
||||||
os.makedirs(os.path.dirname(filename), exist_ok=True)
|
filename = self.__prepare_filename(filename_template, lambda: post.url)
|
||||||
|
|
||||||
# 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
|
||||||
self._committed = self.check_if_committed(filename)
|
if post.typename == 'GraphSidecar':
|
||||||
if self.download_pictures:
|
if self.download_pictures or self.download_videos:
|
||||||
if post.typename == 'GraphSidecar':
|
if not _all_already_downloaded(
|
||||||
edge_number = 1
|
filename_template, enumerate(
|
||||||
for sidecar_node in post.get_sidecar_nodes():
|
(post.get_is_videos()[i]
|
||||||
# Download picture or video thumbnail
|
for i in range(self.slide_start % post.mediacount, self.slide_end % post.mediacount + 1)),
|
||||||
if not sidecar_node.is_video or self.download_video_thumbnails is True:
|
start=self.slide_start % post.mediacount + 1
|
||||||
downloaded &= self.download_pic(filename=filename, url=sidecar_node.display_url,
|
)
|
||||||
mtime=post.date_local, filename_suffix=str(edge_number))
|
):
|
||||||
# Additionally download video if available and desired
|
for edge_number, sidecar_node in enumerate(
|
||||||
if sidecar_node.is_video and self.download_videos is True:
|
post.get_sidecar_nodes(self.slide_start, self.slide_end),
|
||||||
downloaded &= self.download_pic(filename=filename, url=sidecar_node.video_url,
|
start=self.slide_start % post.mediacount + 1
|
||||||
mtime=post.date_local, filename_suffix=str(edge_number))
|
):
|
||||||
edge_number += 1
|
suffix = str(edge_number) # type: Optional[str]
|
||||||
elif post.typename == 'GraphImage':
|
if '{filename}' in self.filename_pattern:
|
||||||
downloaded = self.download_pic(filename=filename, url=post.url, mtime=post.date_local)
|
suffix = None
|
||||||
elif post.typename == 'GraphVideo':
|
if self.download_pictures and (not sidecar_node.is_video or self.download_video_thumbnails):
|
||||||
if self.download_video_thumbnails is True:
|
# pylint:disable=cell-var-from-loop
|
||||||
downloaded = self.download_pic(filename=filename, url=post.url, mtime=post.date_local)
|
sidecar_filename = self.__prepare_filename(filename_template,
|
||||||
else:
|
lambda: sidecar_node.display_url)
|
||||||
self.context.error("Warning: {0} has unknown typename: {1}".format(post, post.typename))
|
# Download sidecar picture or video thumbnail (--no-pictures implies --no-video-thumbnails)
|
||||||
|
downloaded &= self.download_pic(filename=sidecar_filename, url=sidecar_node.display_url,
|
||||||
|
mtime=post.date_local, filename_suffix=suffix)
|
||||||
|
if sidecar_node.is_video and self.download_videos:
|
||||||
|
# pylint:disable=cell-var-from-loop
|
||||||
|
sidecar_filename = self.__prepare_filename(filename_template,
|
||||||
|
lambda: sidecar_node.video_url)
|
||||||
|
# Download sidecar video if desired
|
||||||
|
downloaded &= self.download_pic(filename=sidecar_filename, url=sidecar_node.video_url,
|
||||||
|
mtime=post.date_local, filename_suffix=suffix)
|
||||||
|
else:
|
||||||
|
downloaded = False
|
||||||
|
elif post.typename == 'GraphImage':
|
||||||
|
# Download picture
|
||||||
|
if self.download_pictures:
|
||||||
|
downloaded = (not _already_downloaded(filename + ".jpg") and
|
||||||
|
self.download_pic(filename=filename, url=post.url, mtime=post.date_local))
|
||||||
|
elif post.typename == 'GraphVideo':
|
||||||
|
# Download video thumbnail (--no-pictures implies --no-video-thumbnails)
|
||||||
|
if self.download_pictures and self.download_video_thumbnails:
|
||||||
|
with self.context.error_catcher("Video thumbnail of {}".format(post)):
|
||||||
|
downloaded = (not _already_downloaded(filename + ".jpg") and
|
||||||
|
self.download_pic(filename=filename, url=post.url, mtime=post.date_local))
|
||||||
|
else:
|
||||||
|
self.context.error("Warning: {0} has unknown typename: {1}".format(post, post.typename))
|
||||||
|
|
||||||
# Save caption if desired
|
# Save caption if desired
|
||||||
metadata_string = _ArbitraryItemFormatter(post).format(self.post_metadata_txt_pattern).strip()
|
metadata_string = _ArbitraryItemFormatter(post).format(self.post_metadata_txt_pattern).strip()
|
||||||
@@ -534,19 +662,20 @@ class Instaloader:
|
|||||||
self.save_caption(filename=filename, mtime=post.date_local, caption=metadata_string)
|
self.save_caption(filename=filename, mtime=post.date_local, caption=metadata_string)
|
||||||
|
|
||||||
# Download video if desired
|
# Download video if desired
|
||||||
if post.is_video and self.download_videos is True:
|
if post.is_video and self.download_videos:
|
||||||
downloaded &= self.download_pic(filename=filename, url=post.video_url, mtime=post.date_local)
|
downloaded &= (not _already_downloaded(filename + ".mp4") and
|
||||||
|
self.download_pic(filename=filename, url=post.video_url, mtime=post.date_local))
|
||||||
|
|
||||||
# Download geotags if desired
|
# Download geotags if desired
|
||||||
if self.download_geotags and post.location:
|
if self.download_geotags and post.location:
|
||||||
self.save_location(filename, post.location, post.date_local)
|
self.save_location(filename, post.location, post.date_local)
|
||||||
|
|
||||||
# Update comments if desired
|
# Update comments if desired
|
||||||
if self.download_comments is True:
|
if self.download_comments:
|
||||||
self.update_comments(filename=filename, post=post)
|
self.update_comments(filename=filename, post=post)
|
||||||
|
|
||||||
# Save metadata as JSON if desired.
|
# Save metadata as JSON if desired.
|
||||||
if self.save_metadata is not False:
|
if self.save_metadata:
|
||||||
self.save_metadata_json(filename, post)
|
self.save_metadata_json(filename, post)
|
||||||
|
|
||||||
self.context.log()
|
self.context.log()
|
||||||
@@ -571,12 +700,12 @@ class Instaloader:
|
|||||||
|
|
||||||
def _userid_chunks():
|
def _userid_chunks():
|
||||||
assert userids is not None
|
assert userids is not None
|
||||||
userids_per_query = 100
|
userids_per_query = 50
|
||||||
for i in range(0, len(userids), userids_per_query):
|
for i in range(0, len(userids), userids_per_query):
|
||||||
yield userids[i:i + userids_per_query]
|
yield userids[i:i + userids_per_query]
|
||||||
|
|
||||||
for userid_chunk in _userid_chunks():
|
for userid_chunk in _userid_chunks():
|
||||||
stories = self.context.graphql_query("bf41e22b1c4ba4c9f31b844ebb7d9056",
|
stories = self.context.graphql_query("303a4ae99711322310f25250d988f3b7",
|
||||||
{"reel_ids": userid_chunk, "precomposed_overlay": False})["data"]
|
{"reel_ids": userid_chunk, "precomposed_overlay": False})["data"]
|
||||||
yield from (Story(self.context, media) for media in stories['reels_media'])
|
yield from (Story(self.context, media) for media in stories['reels_media'])
|
||||||
|
|
||||||
@@ -628,16 +757,25 @@ class Instaloader:
|
|||||||
:return: True if something was downloaded, False otherwise, i.e. file was already there
|
:return: True if something was downloaded, False otherwise, i.e. file was already there
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def _already_downloaded(path: str) -> bool:
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
self.context.log(path + ' exists', end=' ', flush=True)
|
||||||
|
return True
|
||||||
|
|
||||||
date_local = item.date_local
|
date_local = item.date_local
|
||||||
dirname = _PostPathFormatter(item).format(self.dirname_pattern, target=target)
|
dirname = _PostPathFormatter(item).format(self.dirname_pattern, target=target)
|
||||||
filename = os.path.join(dirname, self.format_filename(item, target=target))
|
filename_template = os.path.join(dirname, self.format_filename(item, target=target))
|
||||||
os.makedirs(os.path.dirname(filename), exist_ok=True)
|
filename = self.__prepare_filename(filename_template, lambda: item.url)
|
||||||
downloaded = False
|
downloaded = False
|
||||||
if not item.is_video or self.download_video_thumbnails is True:
|
if not item.is_video or self.download_video_thumbnails is True:
|
||||||
url = item.url
|
downloaded = (not _already_downloaded(filename + ".jpg") and
|
||||||
downloaded = self.download_pic(filename=filename, url=url, mtime=date_local)
|
self.download_pic(filename=filename, url=item.url, mtime=date_local))
|
||||||
if item.is_video and self.download_videos is True:
|
if item.is_video and self.download_videos is True:
|
||||||
downloaded |= self.download_pic(filename=filename, url=item.video_url, mtime=date_local)
|
filename = self.__prepare_filename(filename_template, lambda: str(item.video_url))
|
||||||
|
downloaded |= (not _already_downloaded(filename + ".mp4") and
|
||||||
|
self.download_pic(filename=filename, url=item.video_url, mtime=date_local))
|
||||||
# Save caption if desired
|
# Save caption if desired
|
||||||
metadata_string = _ArbitraryItemFormatter(item).format(self.storyitem_metadata_txt_pattern).strip()
|
metadata_string = _ArbitraryItemFormatter(item).format(self.storyitem_metadata_txt_pattern).strip()
|
||||||
if metadata_string:
|
if metadata_string:
|
||||||
@@ -719,59 +857,77 @@ class Instaloader:
|
|||||||
fast_update: bool = False,
|
fast_update: bool = False,
|
||||||
post_filter: Optional[Callable[[Post], bool]] = None,
|
post_filter: Optional[Callable[[Post], bool]] = None,
|
||||||
max_count: Optional[int] = None,
|
max_count: Optional[int] = None,
|
||||||
total_count: Optional[int] = None) -> None:
|
total_count: Optional[int] = None,
|
||||||
|
owner_profile: Optional[Profile] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Download the Posts returned by given Post Iterator.
|
Download the Posts returned by given Post Iterator.
|
||||||
|
|
||||||
..versionadded:: 4.4
|
.. versionadded:: 4.4
|
||||||
|
|
||||||
|
.. versionchanged:: 4.5
|
||||||
|
Transparently resume an aborted operation if `posts` is a :class:`NodeIterator`.
|
||||||
|
|
||||||
: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`.
|
||||||
:param post_filter: :option:`--post-filter`
|
:param post_filter: :option:`--post-filter`.
|
||||||
:param max_count: Maximum count of Posts to download (:option:`--count`)
|
:param max_count: Maximum count of Posts to download (:option:`--count`).
|
||||||
: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.
|
||||||
"""
|
"""
|
||||||
for number, post in enumerate(posts):
|
displayed_count = (max_count if total_count is None or max_count is not None and max_count < total_count
|
||||||
if max_count is not None and number >= max_count:
|
else total_count)
|
||||||
break
|
sanitized_target = target
|
||||||
if total_count is not None:
|
if isinstance(target, str):
|
||||||
self.context.log("[{0:{w}d}/{1:{w}d}] ".format(number + 1, total_count,
|
sanitized_target = _PostPathFormatter.sanitize_path(target)
|
||||||
w=len(str(total_count))),
|
with resumable_iteration(
|
||||||
end="", flush=True)
|
context=self.context,
|
||||||
else:
|
iterator=posts,
|
||||||
if max_count is not None:
|
load=load_structure_from_file,
|
||||||
self.context.log("[{0:{w}d}/{1:{w}d}] ".format(number + 1, max_count,
|
save=save_structure_to_file,
|
||||||
w=len(str(max_count))),
|
format_path=lambda magic: self.format_filename_within_target_path(
|
||||||
|
sanitized_target, owner_profile, self.resume_prefix or '', magic, 'json.xz'
|
||||||
|
),
|
||||||
|
check_bbd=self.check_resume_bbd,
|
||||||
|
enabled=self.resume_prefix is not None
|
||||||
|
) as (is_resuming, start_index):
|
||||||
|
for number, post in enumerate(posts, start=start_index + 1):
|
||||||
|
if max_count is not None and number > max_count:
|
||||||
|
break
|
||||||
|
if displayed_count is not None:
|
||||||
|
self.context.log("[{0:{w}d}/{1:{w}d}] ".format(number, displayed_count,
|
||||||
|
w=len(str(displayed_count))),
|
||||||
end="", flush=True)
|
end="", flush=True)
|
||||||
else:
|
else:
|
||||||
self.context.log("[{:3d}] ".format(number + 1), end="", flush=True)
|
self.context.log("[{:3d}] ".format(number), end="", flush=True)
|
||||||
if post_filter is not None:
|
if post_filter is not None:
|
||||||
try:
|
|
||||||
if not post_filter(post):
|
|
||||||
self.context.log("{} skipped".format(post))
|
|
||||||
continue
|
|
||||||
except (InstaloaderException, KeyError, TypeError) as err:
|
|
||||||
self.context.error("{} skipped. Filter evaluation failed: {}".format(post, err))
|
|
||||||
continue
|
|
||||||
with self.context.error_catcher("Download {} of {}".format(post, target)):
|
|
||||||
# The PostChangedException gets raised if the Post's id/shortcode changed while obtaining
|
|
||||||
# additional metadata. This is most likely the case if a HTTP redirect takes place while
|
|
||||||
# resolving the shortcode URL.
|
|
||||||
# The `post_changed` variable keeps the fast-update functionality alive: A Post which is
|
|
||||||
# obained after a redirect has probably already been downloaded as a previous Post of the
|
|
||||||
# same Profile.
|
|
||||||
# Observed in issue #225: https://github.com/instaloader/instaloader/issues/225
|
|
||||||
post_changed = False
|
|
||||||
while True:
|
|
||||||
try:
|
try:
|
||||||
downloaded = self.download_post(post, target=target)
|
if not post_filter(post):
|
||||||
break
|
self.context.log("{} skipped".format(post))
|
||||||
except PostChangedException:
|
continue
|
||||||
post_changed = True
|
except (InstaloaderException, KeyError, TypeError) as err:
|
||||||
|
self.context.error("{} skipped. Filter evaluation failed: {}".format(post, err))
|
||||||
continue
|
continue
|
||||||
if fast_update and not downloaded and not post_changed:
|
with self.context.error_catcher("Download {} of {}".format(post, target)):
|
||||||
break
|
# The PostChangedException gets raised if the Post's id/shortcode changed while obtaining
|
||||||
|
# additional metadata. This is most likely the case if a HTTP redirect takes place while
|
||||||
|
# resolving the shortcode URL.
|
||||||
|
# The `post_changed` variable keeps the fast-update functionality alive: A Post which is
|
||||||
|
# obained after a redirect has probably already been downloaded as a previous Post of the
|
||||||
|
# same Profile.
|
||||||
|
# Observed in issue #225: https://github.com/instaloader/instaloader/issues/225
|
||||||
|
post_changed = False
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
downloaded = self.download_post(post, target=target)
|
||||||
|
break
|
||||||
|
except PostChangedException:
|
||||||
|
post_changed = True
|
||||||
|
continue
|
||||||
|
if fast_update and not downloaded and not post_changed:
|
||||||
|
# disengage fast_update for first post when resuming
|
||||||
|
if not is_resuming or number > 0:
|
||||||
|
break
|
||||||
|
|
||||||
@_requires_login
|
@_requires_login
|
||||||
def get_feed_posts(self) -> Iterator[Post]:
|
def get_feed_posts(self) -> Iterator[Post]:
|
||||||
@@ -831,8 +987,10 @@ class Instaloader:
|
|||||||
"""
|
"""
|
||||||
self.context.log("Retrieving saved posts...")
|
self.context.log("Retrieving saved posts...")
|
||||||
assert self.context.username is not None # safe due to @_requires_login; required by typechecker
|
assert self.context.username is not None # safe due to @_requires_login; required by typechecker
|
||||||
self.posts_download_loop(Profile.from_username(self.context, self.context.username).get_saved_posts(), ":saved",
|
node_iterator = Profile.own_profile(self.context).get_saved_posts()
|
||||||
fast_update, post_filter, max_count=max_count)
|
self.posts_download_loop(node_iterator, ":saved",
|
||||||
|
fast_update, post_filter,
|
||||||
|
max_count=max_count, total_count=node_iterator.count)
|
||||||
|
|
||||||
@_requires_login
|
@_requires_login
|
||||||
def get_location_posts(self, location: str) -> Iterator[Post]:
|
def get_location_posts(self, location: str) -> Iterator[Post]:
|
||||||
@@ -887,18 +1045,20 @@ class Instaloader:
|
|||||||
max_count=max_count)
|
max_count=max_count)
|
||||||
|
|
||||||
@_requires_login
|
@_requires_login
|
||||||
def get_explore_posts(self) -> Iterator[Post]:
|
def get_explore_posts(self) -> NodeIterator[Post]:
|
||||||
"""Get Posts which are worthy of exploring suggested by Instagram.
|
"""Get Posts which are worthy of exploring suggested by Instagram.
|
||||||
|
|
||||||
:return: Iterator over Posts of the user's suggested posts.
|
:return: Iterator over Posts of the user's suggested posts.
|
||||||
|
:rtype: NodeIterator[Post]
|
||||||
:raises LoginRequiredException: If called without being logged in.
|
:raises LoginRequiredException: If called without being logged in.
|
||||||
"""
|
"""
|
||||||
data = self.context.get_json('explore/', {})
|
return NodeIterator(
|
||||||
yield from (Post(self.context, node)
|
self.context,
|
||||||
for node in self.context.graphql_node_list("df0dcc250c2b18d9fd27c5581ef33c7c",
|
'df0dcc250c2b18d9fd27c5581ef33c7c',
|
||||||
{}, 'https://www.instagram.com/explore/',
|
lambda d: d['data']['user']['edge_web_discover_media'],
|
||||||
lambda d: d['data']['user']['edge_web_discover_media'],
|
lambda n: Post(self.context, n),
|
||||||
data.get('rhx_gis')))
|
query_referer='https://www.instagram.com/explore/',
|
||||||
|
)
|
||||||
|
|
||||||
def get_hashtag_posts(self, hashtag: str) -> Iterator[Post]:
|
def get_hashtag_posts(self, hashtag: str) -> Iterator[Post]:
|
||||||
"""Get Posts associated with a #hashtag.
|
"""Get Posts associated with a #hashtag.
|
||||||
@@ -969,7 +1129,7 @@ class Instaloader:
|
|||||||
.. versionadded:: 4.3"""
|
.. versionadded:: 4.3"""
|
||||||
self.context.log("Retrieving IGTV videos for profile {}.".format(profile.username))
|
self.context.log("Retrieving IGTV videos for profile {}.".format(profile.username))
|
||||||
self.posts_download_loop(profile.get_igtv_posts(), profile.username, fast_update, post_filter,
|
self.posts_download_loop(profile.get_igtv_posts(), profile.username, fast_update, post_filter,
|
||||||
total_count=profile.igtvcount)
|
total_count=profile.igtvcount, owner_profile=profile)
|
||||||
|
|
||||||
def _get_id_filename(self, profile_name: str) -> str:
|
def _get_id_filename(self, profile_name: str) -> str:
|
||||||
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
||||||
@@ -1079,11 +1239,11 @@ class Instaloader:
|
|||||||
def _error_raiser(_str):
|
def _error_raiser(_str):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# error_handler type is Callable[[Optional[str]], ContextManager[None]] (not supported with Python 3.5)
|
# error_handler type is Callable[[Optional[str]], ContextManager[None]] (not supported with Python 3.5.0..3.5.3)
|
||||||
error_handler = _error_raiser if raise_errors else self.context.error_catcher
|
error_handler = _error_raiser if raise_errors else self.context.error_catcher
|
||||||
|
|
||||||
for profile in profiles:
|
for profile in profiles:
|
||||||
with error_handler(profile.username): # type: ignore
|
with error_handler(profile.username): # type: ignore # (ignore type for Python 3.5 support)
|
||||||
profile_name = profile.username
|
profile_name = profile.username
|
||||||
|
|
||||||
# Download profile picture
|
# Download profile picture
|
||||||
@@ -1099,10 +1259,13 @@ class Instaloader:
|
|||||||
self.save_metadata_json(json_filename, profile)
|
self.save_metadata_json(json_filename, profile)
|
||||||
|
|
||||||
# Catch some errors
|
# Catch some errors
|
||||||
if profile.is_private and (tagged or igtv or highlights or posts):
|
if tagged or igtv or highlights or posts:
|
||||||
if not self.context.is_logged_in:
|
if (not self.context.is_logged_in and
|
||||||
|
profile.is_private):
|
||||||
raise LoginRequiredException("--login=USERNAME required.")
|
raise LoginRequiredException("--login=USERNAME required.")
|
||||||
if not profile.followed_by_viewer and self.context.username != profile.username:
|
if (self.context.username != profile.username and
|
||||||
|
profile.is_private and
|
||||||
|
not profile.followed_by_viewer):
|
||||||
raise PrivateProfileNotFollowedException("Private but not followed.")
|
raise PrivateProfileNotFollowedException("Private but not followed.")
|
||||||
|
|
||||||
# Download tagged, if requested
|
# Download tagged, if requested
|
||||||
@@ -1124,7 +1287,7 @@ class Instaloader:
|
|||||||
if posts:
|
if posts:
|
||||||
self.context.log("Retrieving posts from profile {}.".format(profile_name))
|
self.context.log("Retrieving posts from profile {}.".format(profile_name))
|
||||||
self.posts_download_loop(profile.get_posts(), profile_name, fast_update, post_filter,
|
self.posts_download_loop(profile.get_posts(), profile_name, fast_update, post_filter,
|
||||||
total_count=profile.mediacount)
|
total_count=profile.mediacount, owner_profile=profile)
|
||||||
|
|
||||||
if stories and profiles:
|
if stories and profiles:
|
||||||
with self.context.error_catcher("Download stories"):
|
with self.context.error_catcher("Download stories"):
|
||||||
@@ -1204,26 +1367,7 @@ class Instaloader:
|
|||||||
# Iterate over pictures and download them
|
# Iterate over pictures and download them
|
||||||
self.context.log("Retrieving posts from profile {}.".format(profile_name))
|
self.context.log("Retrieving posts from profile {}.".format(profile_name))
|
||||||
self.posts_download_loop(profile.get_posts(), profile_name, fast_update, post_filter,
|
self.posts_download_loop(profile.get_posts(), profile_name, fast_update, post_filter,
|
||||||
total_count=profile.mediacount)
|
total_count=profile.mediacount, owner_profile=profile)
|
||||||
|
|
||||||
def check_if_committed(self, filename: str) -> bool:
|
|
||||||
"""Checks to see if the current post has been committed.
|
|
||||||
|
|
||||||
A post is considered committed if its json metadata file exists and is not malformed.
|
|
||||||
|
|
||||||
.. versionadded:: 4.2
|
|
||||||
"""
|
|
||||||
if os.path.isfile(filename + '.json.xz'):
|
|
||||||
filename += '.json.xz'
|
|
||||||
elif os.path.isfile(filename + '.json'):
|
|
||||||
filename += '.json'
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
load_structure_from_file(self.context, filename)
|
|
||||||
return True
|
|
||||||
except (FileNotFoundError, lzma.LZMAError, json.decoder.JSONDecodeError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
+216
-111
@@ -1,5 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
@@ -23,17 +24,16 @@ def copy_session(session: requests.Session, request_timeout: Optional[float] = N
|
|||||||
"""Duplicates a requests.Session."""
|
"""Duplicates a requests.Session."""
|
||||||
new = requests.Session()
|
new = requests.Session()
|
||||||
new.cookies = requests.utils.cookiejar_from_dict(requests.utils.dict_from_cookiejar(session.cookies))
|
new.cookies = requests.utils.cookiejar_from_dict(requests.utils.dict_from_cookiejar(session.cookies))
|
||||||
new.headers = session.headers.copy() # type: ignore
|
new.headers = session.headers.copy()
|
||||||
if request_timeout is not None:
|
# 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
|
new.request = partial(new.request, timeout=request_timeout) # type: ignore
|
||||||
new.request = partial(new.request, timeout=request_timeout) # type: ignore
|
|
||||||
return new
|
return new
|
||||||
|
|
||||||
|
|
||||||
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/51.0.2704.79 Safari/537.36'
|
'(KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36'
|
||||||
|
|
||||||
|
|
||||||
class InstaloaderContext:
|
class InstaloaderContext:
|
||||||
@@ -52,7 +52,9 @@ class InstaloaderContext:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, sleep: bool = True, quiet: bool = False, user_agent: Optional[str] = None,
|
def __init__(self, sleep: bool = True, quiet: bool = False, user_agent: Optional[str] = None,
|
||||||
max_connection_attempts: int = 3, request_timeout: Optional[float] = None):
|
max_connection_attempts: int = 3, request_timeout: float = 300.0,
|
||||||
|
rate_controller: Optional[Callable[["InstaloaderContext"], "RateController"]] = None,
|
||||||
|
fatal_status_codes: Optional[List[int]] = None):
|
||||||
|
|
||||||
self.user_agent = user_agent if user_agent is not None else default_user_agent()
|
self.user_agent = user_agent if user_agent is not None else default_user_agent()
|
||||||
self.request_timeout = request_timeout
|
self.request_timeout = request_timeout
|
||||||
@@ -68,13 +70,14 @@ class InstaloaderContext:
|
|||||||
# error log, filled with error() and printed at the end of Instaloader.main()
|
# error log, filled with error() and printed at the end of Instaloader.main()
|
||||||
self.error_log = [] # type: List[str]
|
self.error_log = [] # type: List[str]
|
||||||
|
|
||||||
# For the adaption of sleep intervals (rate control)
|
self._rate_controller = rate_controller(self) if rate_controller is not None else RateController(self)
|
||||||
self._graphql_query_timestamps = dict() # type: Dict[str, List[float]]
|
|
||||||
self._graphql_earliest_next_request_time = 0.0
|
|
||||||
|
|
||||||
# Can be set to True for testing, disables supression of InstaloaderContext._error_catcher
|
# Can be set to True for testing, disables supression of InstaloaderContext._error_catcher
|
||||||
self.raise_all_errors = False
|
self.raise_all_errors = False
|
||||||
|
|
||||||
|
# HTTP status codes that should cause an AbortDownloadException
|
||||||
|
self.fatal_status_codes = fatal_status_codes or []
|
||||||
|
|
||||||
# Cache profile from id (mapping from id to Profile)
|
# Cache profile from id (mapping from id to Profile)
|
||||||
self.profile_id_cache = dict() # type: Dict[int, Any]
|
self.profile_id_cache = dict() # type: Dict[int, Any]
|
||||||
|
|
||||||
@@ -161,10 +164,9 @@ class InstaloaderContext:
|
|||||||
'ig_vw': '1920', 'csrftoken': '',
|
'ig_vw': '1920', 'csrftoken': '',
|
||||||
's_network': '', 'ds_user_id': ''})
|
's_network': '', 'ds_user_id': ''})
|
||||||
session.headers.update(self._default_http_header(empty_session_only=True))
|
session.headers.update(self._default_http_header(empty_session_only=True))
|
||||||
if self.request_timeout is not None:
|
# 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
|
|
||||||
return session
|
return session
|
||||||
|
|
||||||
def save_session_to_file(self, sessionfile):
|
def save_session_to_file(self, sessionfile):
|
||||||
@@ -177,10 +179,9 @@ class InstaloaderContext:
|
|||||||
session.cookies = requests.utils.cookiejar_from_dict(pickle.load(sessionfile))
|
session.cookies = requests.utils.cookiejar_from_dict(pickle.load(sessionfile))
|
||||||
session.headers.update(self._default_http_header())
|
session.headers.update(self._default_http_header())
|
||||||
session.headers.update({'X-CSRFToken': session.cookies.get_dict()['csrftoken']})
|
session.headers.update({'X-CSRFToken': session.cookies.get_dict()['csrftoken']})
|
||||||
if self.request_timeout is not None:
|
# 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
|
|
||||||
self._session = session
|
self._session = session
|
||||||
self.username = username
|
self.username = username
|
||||||
|
|
||||||
@@ -206,10 +207,9 @@ 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())
|
||||||
if self.request_timeout is not None:
|
# 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
|
|
||||||
session.get('https://www.instagram.com/web/__mid/')
|
session.get('https://www.instagram.com/web/__mid/')
|
||||||
csrf_token = session.cookies.get_dict()['csrftoken']
|
csrf_token = session.cookies.get_dict()['csrftoken']
|
||||||
session.headers.update({'X-CSRFToken': csrf_token})
|
session.headers.update({'X-CSRFToken': csrf_token})
|
||||||
@@ -222,8 +222,10 @@ class InstaloaderContext:
|
|||||||
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:
|
except json.decoder.JSONDecodeError as err:
|
||||||
raise ConnectionException("Login error: JSON decode fail, {} - {}.".format(login.status_code, login.reason))
|
raise ConnectionException(
|
||||||
|
"Login error: JSON decode fail, {} - {}.".format(login.status_code, login.reason)
|
||||||
|
) from err
|
||||||
if resp_json.get('two_factor_required'):
|
if resp_json.get('two_factor_required'):
|
||||||
two_factor_session = copy_session(session, self.request_timeout)
|
two_factor_session = copy_session(session, self.request_timeout)
|
||||||
two_factor_session.headers.update({'X-CSRFToken': csrf_token})
|
two_factor_session.headers.update({'X-CSRFToken': csrf_token})
|
||||||
@@ -292,78 +294,7 @@ class InstaloaderContext:
|
|||||||
def do_sleep(self):
|
def do_sleep(self):
|
||||||
"""Sleep a short time if self.sleep is set. Called before each request to instagram.com."""
|
"""Sleep a short time if self.sleep is set. Called before each request to instagram.com."""
|
||||||
if self.sleep:
|
if self.sleep:
|
||||||
time.sleep(min(random.expovariate(0.7), 5.0))
|
time.sleep(min(random.expovariate(0.6), 15.0))
|
||||||
|
|
||||||
def _dump_query_timestamps(self, current_time: float):
|
|
||||||
"""Output the number of GraphQL queries grouped by their query_hash within the last time."""
|
|
||||||
windows = [10, 11, 15, 20, 30, 60]
|
|
||||||
print("GraphQL requests:", file=sys.stderr)
|
|
||||||
for query_hash, times in self._graphql_query_timestamps.items():
|
|
||||||
print(" {}".format(query_hash), file=sys.stderr)
|
|
||||||
for window in windows:
|
|
||||||
reqs_in_sliding_window = sum(t > current_time - window * 60 for t in times)
|
|
||||||
print(" last {} minutes: {} requests".format(window, reqs_in_sliding_window), file=sys.stderr)
|
|
||||||
|
|
||||||
def _graphql_request_count_per_sliding_window(self, query_hash: str) -> int:
|
|
||||||
"""Return how many GraphQL requests can be done within the sliding window."""
|
|
||||||
if self.is_logged_in:
|
|
||||||
max_reqs = {'1cb6ec562846122743b61e492c85999f': 20,
|
|
||||||
'33ba35852cb50da46f5b5e889df7d159': 20,
|
|
||||||
'iphone': 100,
|
|
||||||
'other': 100}
|
|
||||||
else:
|
|
||||||
max_reqs = {'1cb6ec562846122743b61e492c85999f': 200,
|
|
||||||
'33ba35852cb50da46f5b5e889df7d159': 200,
|
|
||||||
'other': 200}
|
|
||||||
return max_reqs.get(query_hash) or min(max_reqs.values())
|
|
||||||
|
|
||||||
def _graphql_query_waittime(self, query_hash: str, current_time: float, untracked_queries: bool = False) -> float:
|
|
||||||
"""Calculate time needed to wait before GraphQL query can be executed."""
|
|
||||||
sliding_window = 660
|
|
||||||
if query_hash not in self._graphql_query_timestamps:
|
|
||||||
self._graphql_query_timestamps[query_hash] = []
|
|
||||||
self._graphql_query_timestamps[query_hash] = list(filter(lambda t: t > current_time - 60 * 60,
|
|
||||||
self._graphql_query_timestamps[query_hash]))
|
|
||||||
reqs_in_sliding_window = list(filter(lambda t: t > current_time - sliding_window,
|
|
||||||
self._graphql_query_timestamps[query_hash]))
|
|
||||||
count_per_sliding_window = self._graphql_request_count_per_sliding_window(query_hash)
|
|
||||||
if len(reqs_in_sliding_window) < count_per_sliding_window and not untracked_queries:
|
|
||||||
return max(0, self._graphql_earliest_next_request_time - current_time)
|
|
||||||
next_request_time = min(reqs_in_sliding_window) + sliding_window + 6
|
|
||||||
if untracked_queries:
|
|
||||||
self._graphql_earliest_next_request_time = next_request_time
|
|
||||||
return round(max(next_request_time, self._graphql_earliest_next_request_time) - current_time)
|
|
||||||
|
|
||||||
def _ratecontrol_graphql_query(self, query_hash: str, untracked_queries: bool = False):
|
|
||||||
"""Called before a GraphQL query is made in order to stay within Instagram's rate limits.
|
|
||||||
|
|
||||||
:param query_hash: The query_hash parameter of the query.
|
|
||||||
:param untracked_queries: True, if 429 has been returned to apply 429 logic.
|
|
||||||
"""
|
|
||||||
if not untracked_queries:
|
|
||||||
waittime = self._graphql_query_waittime(query_hash, time.monotonic(), untracked_queries)
|
|
||||||
assert waittime >= 0
|
|
||||||
if waittime > 10:
|
|
||||||
self.log('\nToo many queries in the last time. Need to wait {} seconds, until {:%H:%M}.'
|
|
||||||
.format(waittime, datetime.now() + timedelta(seconds=waittime)))
|
|
||||||
time.sleep(waittime)
|
|
||||||
if query_hash not in self._graphql_query_timestamps:
|
|
||||||
self._graphql_query_timestamps[query_hash] = [time.monotonic()]
|
|
||||||
else:
|
|
||||||
self._graphql_query_timestamps[query_hash].append(time.monotonic())
|
|
||||||
else:
|
|
||||||
text_for_429 = ("HTTP error code 429 was returned because too many queries occurred in the last time. "
|
|
||||||
"Please do not use Instagram in your browser or run multiple instances of Instaloader "
|
|
||||||
"in parallel.")
|
|
||||||
print(textwrap.fill(text_for_429), file=sys.stderr)
|
|
||||||
current_time = time.monotonic()
|
|
||||||
waittime = self._graphql_query_waittime(query_hash, current_time, untracked_queries)
|
|
||||||
assert waittime >= 0
|
|
||||||
if waittime > 10:
|
|
||||||
self.log('The request will be retried in {} seconds, at {:%H:%M}.'
|
|
||||||
.format(waittime, datetime.now() + timedelta(seconds=waittime)))
|
|
||||||
self._dump_query_timestamps(current_time)
|
|
||||||
time.sleep(waittime)
|
|
||||||
|
|
||||||
def get_json(self, path: str, params: Dict[str, Any], host: str = 'www.instagram.com',
|
def get_json(self, path: str, params: Dict[str, Any], host: str = 'www.instagram.com',
|
||||||
session: Optional[requests.Session] = None, _attempt=1) -> Dict[str, Any]:
|
session: Optional[requests.Session] = None, _attempt=1) -> Dict[str, Any]:
|
||||||
@@ -385,18 +316,25 @@ class InstaloaderContext:
|
|||||||
try:
|
try:
|
||||||
self.do_sleep()
|
self.do_sleep()
|
||||||
if is_graphql_query:
|
if is_graphql_query:
|
||||||
self._ratecontrol_graphql_query(params['query_hash'])
|
self._rate_controller.wait_before_query(params['query_hash'])
|
||||||
if is_iphone_query:
|
if is_iphone_query:
|
||||||
self._ratecontrol_graphql_query('iphone')
|
self._rate_controller.wait_before_query('iphone')
|
||||||
if is_other_query:
|
if is_other_query:
|
||||||
self._ratecontrol_graphql_query('other')
|
self._rate_controller.wait_before_query('other')
|
||||||
resp = sess.get('https://{0}/{1}'.format(host, path), params=params, allow_redirects=False)
|
resp = sess.get('https://{0}/{1}'.format(host, path), params=params, allow_redirects=False)
|
||||||
|
if resp.status_code in self.fatal_status_codes:
|
||||||
|
redirect = " redirect to {}".format(resp.headers['location']) if 'location' in resp.headers else ""
|
||||||
|
raise AbortDownloadException("Query to https://{}/{} responded with \"{} {}\"{}".format(
|
||||||
|
host, path, resp.status_code, resp.reason, redirect
|
||||||
|
))
|
||||||
while resp.is_redirect:
|
while resp.is_redirect:
|
||||||
redirect_url = resp.headers['location']
|
redirect_url = resp.headers['location']
|
||||||
self.log('\nHTTP redirect from https://{0}/{1} to {2}'.format(host, path, redirect_url))
|
self.log('\nHTTP redirect from https://{0}/{1} to {2}'.format(host, path, redirect_url))
|
||||||
if redirect_url.startswith('https://www.instagram.com/accounts/login'):
|
if redirect_url.startswith('https://www.instagram.com/accounts/login'):
|
||||||
|
if not self.is_logged_in:
|
||||||
|
raise LoginRequiredException("Redirected to login page. Use --login.")
|
||||||
# alternate rate limit exceeded behavior
|
# alternate rate limit exceeded behavior
|
||||||
raise TooManyRequestsException("429 Too Many Requests: redirected to login")
|
raise TooManyRequestsException("Redirected to login")
|
||||||
if redirect_url.startswith('https://{}/'.format(host)):
|
if redirect_url.startswith('https://{}/'.format(host)):
|
||||||
resp = sess.get(redirect_url if redirect_url.endswith('/') else redirect_url + '/',
|
resp = sess.get(redirect_url if redirect_url.endswith('/') else redirect_url + '/',
|
||||||
params=params, allow_redirects=False)
|
params=params, allow_redirects=False)
|
||||||
@@ -422,10 +360,10 @@ class InstaloaderContext:
|
|||||||
raise ConnectionException("\"window._sharedData\" does not contain required keys.")
|
raise ConnectionException("\"window._sharedData\" does not contain required keys.")
|
||||||
# If GraphQL data is missing in `window._sharedData`, search for it in `__additionalDataLoaded`.
|
# If GraphQL data is missing in `window._sharedData`, search for it in `__additionalDataLoaded`.
|
||||||
if 'graphql' not in post_or_profile_page[0]:
|
if 'graphql' not in post_or_profile_page[0]:
|
||||||
match = re.search(r'window\.__additionalDataLoaded\([^{]+{"graphql":({.*})}\);</script>',
|
match = re.search(r'window\.__additionalDataLoaded\(.*?({.*"graphql":.*})\);</script>',
|
||||||
resp.text)
|
resp.text)
|
||||||
if match is not None:
|
if match is not None:
|
||||||
post_or_profile_page[0]['graphql'] = json.loads(match.group(1))
|
post_or_profile_page[0]['graphql'] = json.loads(match.group(1))['graphql']
|
||||||
return resp_json
|
return resp_json
|
||||||
else:
|
else:
|
||||||
resp_json = resp.json()
|
resp_json = resp.json()
|
||||||
@@ -445,12 +383,13 @@ class InstaloaderContext:
|
|||||||
raise ConnectionException(error_string) from err
|
raise ConnectionException(error_string) from err
|
||||||
self.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False)
|
self.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False)
|
||||||
try:
|
try:
|
||||||
if is_graphql_query and isinstance(err, TooManyRequestsException):
|
if isinstance(err, TooManyRequestsException):
|
||||||
self._ratecontrol_graphql_query(params['query_hash'], untracked_queries=True)
|
if is_graphql_query:
|
||||||
if is_iphone_query and isinstance(err, TooManyRequestsException):
|
self._rate_controller.handle_429(params['query_hash'])
|
||||||
self._ratecontrol_graphql_query('iphone', untracked_queries=True)
|
if is_iphone_query:
|
||||||
if is_other_query and isinstance(err, TooManyRequestsException):
|
self._rate_controller.handle_429('iphone')
|
||||||
self._ratecontrol_graphql_query('other', untracked_queries=True)
|
if is_other_query:
|
||||||
|
self._rate_controller.handle_429('other')
|
||||||
return self.get_json(path=path, params=params, host=host, session=sess, _attempt=_attempt + 1)
|
return self.get_json(path=path, params=params, host=host, session=sess, _attempt=_attempt + 1)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
self.error("[skipped by user]", repeat_at_end=False)
|
self.error("[skipped by user]", repeat_at_end=False)
|
||||||
@@ -498,7 +437,12 @@ class InstaloaderContext:
|
|||||||
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.
|
||||||
|
|
||||||
|
.. deprecated:: 4.5
|
||||||
|
Use :class:`NodeIterator` instead, which provides more functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
def _query():
|
def _query():
|
||||||
query_variables['first'] = self._graphql_page_length
|
query_variables['first'] = self._graphql_page_length
|
||||||
@@ -536,7 +480,7 @@ class InstaloaderContext:
|
|||||||
|
|
||||||
.. versionadded:: 4.2.1"""
|
.. versionadded:: 4.2.1"""
|
||||||
with copy_session(self._session, self.request_timeout) as tempsession:
|
with copy_session(self._session, self.request_timeout) as tempsession:
|
||||||
tempsession.headers['User-Agent'] = 'Instagram 123.1.0.26.115 (iPhone12,1; iOS 13_3; en_US; en-US; ' \
|
tempsession.headers['User-Agent'] = 'Instagram 146.0.0.27.125 (iPhone12,1; iOS 13_3; en_US; en-US; ' \
|
||||||
'scale=2.00; 1656x3584; 190542906)'
|
'scale=2.00; 1656x3584; 190542906)'
|
||||||
for header in ['Host', 'Origin', 'X-Instagram-AJAX', 'X-Requested-With']:
|
for header in ['Host', 'Origin', 'X-Instagram-AJAX', 'X-Requested-With']:
|
||||||
tempsession.headers.pop(header, None)
|
tempsession.headers.pop(header, None)
|
||||||
@@ -547,11 +491,12 @@ class InstaloaderContext:
|
|||||||
|
|
||||||
.. versionadded:: 4.2.1"""
|
.. versionadded:: 4.2.1"""
|
||||||
self.log(filename, end=' ', flush=True)
|
self.log(filename, end=' ', flush=True)
|
||||||
with open(filename, 'wb') as file:
|
with open(filename + '.temp', 'wb') as file:
|
||||||
if isinstance(resp, requests.Response):
|
if isinstance(resp, requests.Response):
|
||||||
shutil.copyfileobj(resp.raw, file)
|
shutil.copyfileobj(resp.raw, file)
|
||||||
else:
|
else:
|
||||||
file.write(resp)
|
file.write(resp)
|
||||||
|
os.replace(filename + '.temp', filename)
|
||||||
|
|
||||||
def get_raw(self, url: str, _attempt=1) -> requests.Response:
|
def get_raw(self, url: str, _attempt=1) -> requests.Response:
|
||||||
"""Downloads a file anonymously.
|
"""Downloads a file anonymously.
|
||||||
@@ -593,3 +538,163 @@ class InstaloaderContext:
|
|||||||
if self._root_rhx_gis is None:
|
if self._root_rhx_gis is None:
|
||||||
self._root_rhx_gis = self.get_json('', {}).get('rhx_gis', '')
|
self._root_rhx_gis = self.get_json('', {}).get('rhx_gis', '')
|
||||||
return self._root_rhx_gis or None
|
return self._root_rhx_gis or None
|
||||||
|
|
||||||
|
|
||||||
|
class RateController:
|
||||||
|
"""
|
||||||
|
Class providing request tracking and rate controlling to stay within rate limits.
|
||||||
|
|
||||||
|
It can be overridden to change Instaloader's behavior regarding rate limits, for example to raise a custom
|
||||||
|
exception when the rate limit is hit::
|
||||||
|
|
||||||
|
import instaloader
|
||||||
|
|
||||||
|
class MyRateController(instaloader.RateController):
|
||||||
|
def sleep(self, secs):
|
||||||
|
raise MyCustomException()
|
||||||
|
|
||||||
|
L = instaloader.Instaloader(rate_controller=lambda ctx: MyRateController(ctx))
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, context: InstaloaderContext):
|
||||||
|
self._context = context
|
||||||
|
self._query_timestamps = dict() # type: Dict[str, List[float]]
|
||||||
|
self._earliest_next_request_time = 0.0
|
||||||
|
self._iphone_earliest_next_request_time = 0.0
|
||||||
|
|
||||||
|
def sleep(self, secs: float):
|
||||||
|
"""Wait given number of seconds."""
|
||||||
|
# Not static, to allow for the behavior of this method to depend on context-inherent properties, such as
|
||||||
|
# whether we are logged in.
|
||||||
|
# pylint:disable=no-self-use
|
||||||
|
time.sleep(secs)
|
||||||
|
|
||||||
|
def _dump_query_timestamps(self, current_time: float, failed_query_type: str):
|
||||||
|
windows = [10, 11, 20, 22, 30, 60]
|
||||||
|
self._context.error("Number of requests within last {} minutes grouped by type:"
|
||||||
|
.format('/'.join(str(w) for w in windows)),
|
||||||
|
repeat_at_end=False)
|
||||||
|
for query_type, times in self._query_timestamps.items():
|
||||||
|
reqs_in_sliding_window = [sum(t > current_time - w * 60 for t in times) for w in windows]
|
||||||
|
self._context.error(" {} {:>32}: {}".format(
|
||||||
|
"*" if query_type == failed_query_type else " ",
|
||||||
|
query_type,
|
||||||
|
" ".join("{:4}".format(reqs) for reqs in reqs_in_sliding_window)
|
||||||
|
), repeat_at_end=False)
|
||||||
|
|
||||||
|
def count_per_sliding_window(self, query_type: str) -> int:
|
||||||
|
"""Return how many requests of the given type can be done within a sliding window of 11 minutes.
|
||||||
|
|
||||||
|
This is called by :meth:`RateController.query_waittime` and allows to simply customize wait times before queries
|
||||||
|
at query_type granularity. Consider overriding :meth:`RateController.query_waittime` directly if you need more
|
||||||
|
control."""
|
||||||
|
# Not static, to allow for the count_per_sliding_window to depend on context-inherent properties, such as
|
||||||
|
# whether we are logged in.
|
||||||
|
# pylint:disable=no-self-use
|
||||||
|
return 75 if query_type == 'other' else 200
|
||||||
|
|
||||||
|
def _reqs_in_sliding_window(self, query_type: Optional[str], current_time: float, window: float) -> List[float]:
|
||||||
|
if query_type is not None:
|
||||||
|
# timestamps of type query_type
|
||||||
|
relevant_timestamps = self._query_timestamps[query_type]
|
||||||
|
else:
|
||||||
|
# all GraphQL queries, i.e. not 'iphone' or 'other'
|
||||||
|
graphql_query_timestamps = filter(lambda tp: tp[0] not in ['iphone', 'other'],
|
||||||
|
self._query_timestamps.items())
|
||||||
|
relevant_timestamps = [t for times in (tp[1] for tp in graphql_query_timestamps) for t in times]
|
||||||
|
return list(filter(lambda t: t > current_time - window, relevant_timestamps))
|
||||||
|
|
||||||
|
def query_waittime(self, query_type: str, current_time: float, untracked_queries: bool = False) -> float:
|
||||||
|
"""Calculate time needed to wait before query can be executed."""
|
||||||
|
per_type_sliding_window = 660
|
||||||
|
iphone_sliding_window = 1800
|
||||||
|
if query_type not in self._query_timestamps:
|
||||||
|
self._query_timestamps[query_type] = []
|
||||||
|
self._query_timestamps[query_type] = list(filter(lambda t: t > current_time - 60 * 60,
|
||||||
|
self._query_timestamps[query_type]))
|
||||||
|
|
||||||
|
def per_type_next_request_time():
|
||||||
|
reqs_in_sliding_window = self._reqs_in_sliding_window(query_type, current_time, per_type_sliding_window)
|
||||||
|
if len(reqs_in_sliding_window) < self.count_per_sliding_window(query_type):
|
||||||
|
return 0.0
|
||||||
|
else:
|
||||||
|
return min(reqs_in_sliding_window) + per_type_sliding_window + 6
|
||||||
|
|
||||||
|
def gql_accumulated_next_request_time():
|
||||||
|
if query_type in ['iphone', 'other']:
|
||||||
|
return 0.0
|
||||||
|
gql_accumulated_sliding_window = 600
|
||||||
|
gql_accumulated_max_count = 275
|
||||||
|
reqs_in_sliding_window = self._reqs_in_sliding_window(None, current_time, gql_accumulated_sliding_window)
|
||||||
|
if len(reqs_in_sliding_window) < gql_accumulated_max_count:
|
||||||
|
return 0.0
|
||||||
|
else:
|
||||||
|
return min(reqs_in_sliding_window) + gql_accumulated_sliding_window
|
||||||
|
|
||||||
|
def untracked_next_request_time():
|
||||||
|
if untracked_queries:
|
||||||
|
if query_type == "iphone":
|
||||||
|
reqs_in_sliding_window = self._reqs_in_sliding_window(query_type, current_time,
|
||||||
|
iphone_sliding_window)
|
||||||
|
self._iphone_earliest_next_request_time = min(reqs_in_sliding_window) + iphone_sliding_window + 18
|
||||||
|
else:
|
||||||
|
reqs_in_sliding_window = self._reqs_in_sliding_window(query_type, current_time,
|
||||||
|
per_type_sliding_window)
|
||||||
|
self._earliest_next_request_time = min(reqs_in_sliding_window) + per_type_sliding_window + 6
|
||||||
|
return max(self._iphone_earliest_next_request_time, self._earliest_next_request_time)
|
||||||
|
|
||||||
|
def iphone_next_request():
|
||||||
|
if query_type == "iphone":
|
||||||
|
reqs_in_sliding_window = self._reqs_in_sliding_window(query_type, current_time, iphone_sliding_window)
|
||||||
|
if len(reqs_in_sliding_window) >= 199:
|
||||||
|
return min(reqs_in_sliding_window) + iphone_sliding_window + 18
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
return max(0.0,
|
||||||
|
max(
|
||||||
|
per_type_next_request_time(),
|
||||||
|
gql_accumulated_next_request_time(),
|
||||||
|
untracked_next_request_time(),
|
||||||
|
iphone_next_request(),
|
||||||
|
) - current_time)
|
||||||
|
|
||||||
|
def wait_before_query(self, query_type: str) -> None:
|
||||||
|
"""This method is called before a query to Instagram.
|
||||||
|
|
||||||
|
It calls :meth:`RateController.query_waittime` to determine the time needed to wait and then calls
|
||||||
|
:meth:`RateController.sleep` to wait until the request can be made."""
|
||||||
|
waittime = self.query_waittime(query_type, time.monotonic(), False)
|
||||||
|
assert waittime >= 0
|
||||||
|
if waittime > 15:
|
||||||
|
formatted_waittime = ("{} seconds".format(round(waittime)) if waittime <= 666 else
|
||||||
|
"{} minutes".format(round(waittime / 60)))
|
||||||
|
self._context.log("\nToo many queries in the last time. Need to wait {}, until {:%H:%M}."
|
||||||
|
.format(formatted_waittime, datetime.now() + timedelta(seconds=waittime)))
|
||||||
|
if waittime > 0:
|
||||||
|
self.sleep(waittime)
|
||||||
|
if query_type not in self._query_timestamps:
|
||||||
|
self._query_timestamps[query_type] = [time.monotonic()]
|
||||||
|
else:
|
||||||
|
self._query_timestamps[query_type].append(time.monotonic())
|
||||||
|
|
||||||
|
def handle_429(self, query_type: str) -> None:
|
||||||
|
"""This method is called to handle a 429 Too Many Requests response.
|
||||||
|
|
||||||
|
It calls :meth:`RateController.query_waittime` to determine the time needed to wait and then calls
|
||||||
|
:meth:`RateController.sleep` to wait until we can repeat the same request."""
|
||||||
|
current_time = time.monotonic()
|
||||||
|
waittime = self.query_waittime(query_type, current_time, True)
|
||||||
|
assert waittime >= 0
|
||||||
|
self._dump_query_timestamps(current_time, query_type)
|
||||||
|
text_for_429 = ("Instagram responded with HTTP error \"429 - Too Many Requests\". Please do not run multiple "
|
||||||
|
"instances of Instaloader in parallel or within short sequence. Also, do not use any Instagram "
|
||||||
|
"App while Instaloader is running.")
|
||||||
|
self._context.error(textwrap.fill(text_for_429), repeat_at_end=False)
|
||||||
|
if waittime > 1.5:
|
||||||
|
formatted_waittime = ("{} seconds".format(round(waittime)) if waittime <= 666 else
|
||||||
|
"{} minutes".format(round(waittime / 60)))
|
||||||
|
self._context.error("The request will be retried in {}, at {:%H:%M}."
|
||||||
|
.format(formatted_waittime, datetime.now() + timedelta(seconds=waittime)),
|
||||||
|
repeat_at_end=False)
|
||||||
|
if waittime > 0:
|
||||||
|
self.sleep(waittime)
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from lzma import LZMAError
|
||||||
|
from typing import Any, Callable, Dict, Iterable, Iterator, NamedTuple, Optional, Tuple, TypeVar
|
||||||
|
|
||||||
|
from .exceptions import AbortDownloadException, InvalidArgumentException, QueryReturnedBadRequestException
|
||||||
|
from .instaloadercontext import InstaloaderContext
|
||||||
|
|
||||||
|
FrozenNodeIterator = NamedTuple('FrozenNodeIterator',
|
||||||
|
[('query_hash', str),
|
||||||
|
('query_variables', Dict),
|
||||||
|
('query_referer', Optional[str]),
|
||||||
|
('context_username', Optional[str]),
|
||||||
|
('total_index', int),
|
||||||
|
('best_before', Optional[float]),
|
||||||
|
('remaining_data', Optional[Dict])])
|
||||||
|
FrozenNodeIterator.__doc__ = \
|
||||||
|
"""A serializable representation of a :class:`NodeIterator` instance, saving its iteration state."""
|
||||||
|
FrozenNodeIterator.query_hash.__doc__ = """The GraphQL ``query_hash`` parameter."""
|
||||||
|
FrozenNodeIterator.query_variables.__doc__ = """The GraphQL ``query_variables`` parameter."""
|
||||||
|
FrozenNodeIterator.query_referer.__doc__ = """The HTTP referer used for the GraphQL query."""
|
||||||
|
FrozenNodeIterator.context_username.__doc__ = """The username who created the iterator, or ``None``."""
|
||||||
|
FrozenNodeIterator.total_index.__doc__ = """Number of items that have already been returned."""
|
||||||
|
FrozenNodeIterator.best_before.__doc__ = """Date when parts of the stored nodes might have expired."""
|
||||||
|
FrozenNodeIterator.remaining_data.__doc__ = \
|
||||||
|
"""The already-retrieved, yet-unprocessed ``edges`` and the ``page_info`` at time of freezing."""
|
||||||
|
|
||||||
|
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
|
||||||
|
class NodeIterator(Iterator[T]):
|
||||||
|
"""
|
||||||
|
Iterate the nodes within edges in a GraphQL pagination. Instances of this class are returned by many (but not all)
|
||||||
|
of Instaloader's :class:`Post`-returning functions (such as :meth:`Profile.get_posts` etc.).
|
||||||
|
|
||||||
|
What makes this iterator special is its ability to freeze/store its current state, e.g. to interrupt an iteration,
|
||||||
|
and later thaw/resume from where it left off.
|
||||||
|
|
||||||
|
You can freeze a NodeIterator with :meth:`NodeIterator.freeze`::
|
||||||
|
|
||||||
|
post_iterator = profile.get_posts()
|
||||||
|
try:
|
||||||
|
for post in post_iterator:
|
||||||
|
do_something_with(post)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
save("resume_information.json", post_iterator.freeze())
|
||||||
|
|
||||||
|
and later reuse it with :meth:`NodeIterator.thaw` on an equally-constructed NodeIterator::
|
||||||
|
|
||||||
|
post_iterator = profile.get_posts()
|
||||||
|
post_iterator.thaw(load("resume_information.json"))
|
||||||
|
|
||||||
|
A :class:`FrozenNodeIterator` can only be thawn with a matching NodeIterator, i.e. a NodeIterator instance that has
|
||||||
|
been constructed with the same parameters as the instance that is represented by the :class:`FrozenNodeIterator` in
|
||||||
|
question. This is to ensure that an iteration cannot be resumed in a wrong, unmatching loop. As a quick way to
|
||||||
|
distinguish iterators that are saved e.g. in files, there is the :attr:`NodeIterator.magic` string: Two
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_graphql_page_length = 50
|
||||||
|
_shelf_life = timedelta(days=29)
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
context: InstaloaderContext,
|
||||||
|
query_hash: str,
|
||||||
|
edge_extractor: Callable[[Dict[str, Any]], Dict[str, Any]],
|
||||||
|
node_wrapper: Callable[[Dict], T],
|
||||||
|
query_variables: Optional[Dict[str, Any]] = None,
|
||||||
|
query_referer: Optional[str] = None,
|
||||||
|
first_data: Optional[Dict[str, Any]] = None):
|
||||||
|
self._context = context
|
||||||
|
self._query_hash = query_hash
|
||||||
|
self._edge_extractor = edge_extractor
|
||||||
|
self._node_wrapper = node_wrapper
|
||||||
|
self._query_variables = query_variables if query_variables is not None else {}
|
||||||
|
self._query_referer = query_referer
|
||||||
|
self._page_index = 0
|
||||||
|
self._total_index = 0
|
||||||
|
if first_data is not None:
|
||||||
|
self._data = first_data
|
||||||
|
self._best_before = datetime.now() + NodeIterator._shelf_life
|
||||||
|
else:
|
||||||
|
self._data = self._query()
|
||||||
|
|
||||||
|
def _query(self, after: Optional[str] = None) -> Dict:
|
||||||
|
pagination_variables = {'first': NodeIterator._graphql_page_length} # type: Dict[str, Any]
|
||||||
|
if after is not None:
|
||||||
|
pagination_variables['after'] = after
|
||||||
|
try:
|
||||||
|
data = self._edge_extractor(
|
||||||
|
self._context.graphql_query(
|
||||||
|
self._query_hash, {**self._query_variables, **pagination_variables}, self._query_referer
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._best_before = datetime.now() + NodeIterator._shelf_life
|
||||||
|
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):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __next__(self) -> T:
|
||||||
|
if self._page_index < len(self._data['edges']):
|
||||||
|
node = self._data['edges'][self._page_index]['node']
|
||||||
|
page_index, total_index = self._page_index, self._total_index
|
||||||
|
try:
|
||||||
|
self._page_index += 1
|
||||||
|
self._total_index += 1
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self._page_index, self._total_index = page_index, total_index
|
||||||
|
raise
|
||||||
|
return self._node_wrapper(node)
|
||||||
|
if self._data['page_info']['has_next_page']:
|
||||||
|
query_response = self._query(self._data['page_info']['end_cursor'])
|
||||||
|
page_index, data = self._page_index, self._data
|
||||||
|
try:
|
||||||
|
self._page_index = 0
|
||||||
|
self._data = query_response
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self._page_index, self._data = page_index, data
|
||||||
|
raise
|
||||||
|
return self.__next__()
|
||||||
|
raise StopIteration()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def count(self) -> Optional[int]:
|
||||||
|
"""The ``count`` as returned by Instagram. This is not always the total count this iterator will yield."""
|
||||||
|
return self._data.get('count') if self._data is not None else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_index(self) -> int:
|
||||||
|
"""Number of items that have already been returned."""
|
||||||
|
return self._total_index
|
||||||
|
|
||||||
|
@property
|
||||||
|
def magic(self) -> str:
|
||||||
|
"""Magic string for easily identifying a matching iterator file for resuming (hash of some parameters)."""
|
||||||
|
magic_hash = hashlib.blake2b(digest_size=6)
|
||||||
|
magic_hash.update(json.dumps(
|
||||||
|
[self._query_hash, self._query_variables, self._query_referer, self._context.username]
|
||||||
|
).encode())
|
||||||
|
return base64.urlsafe_b64encode(magic_hash.digest()).decode()
|
||||||
|
|
||||||
|
def freeze(self) -> FrozenNodeIterator:
|
||||||
|
"""Freeze the iterator for later resuming."""
|
||||||
|
remaining_data = None
|
||||||
|
if self._data is not None:
|
||||||
|
remaining_data = {**self._data,
|
||||||
|
'edges': (self._data['edges'][(max(self._page_index - 1, 0)):])}
|
||||||
|
return FrozenNodeIterator(
|
||||||
|
query_hash=self._query_hash,
|
||||||
|
query_variables=self._query_variables,
|
||||||
|
query_referer=self._query_referer,
|
||||||
|
context_username=self._context.username,
|
||||||
|
total_index=max(self.total_index - 1, 0),
|
||||||
|
best_before=self._best_before.timestamp() if self._best_before else None,
|
||||||
|
remaining_data=remaining_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
def thaw(self, frozen: FrozenNodeIterator) -> None:
|
||||||
|
"""
|
||||||
|
Use this iterator for resuming from earlier iteration.
|
||||||
|
|
||||||
|
:raises InvalidArgumentException:
|
||||||
|
If
|
||||||
|
|
||||||
|
- the iterator on which this method is called has already been used, or
|
||||||
|
- the given :class:`FrozenNodeIterator` does not match, i.e. belongs to a different iteration.
|
||||||
|
"""
|
||||||
|
if self._total_index or self._page_index:
|
||||||
|
raise InvalidArgumentException("thaw() called on already-used iterator.")
|
||||||
|
if (self._query_hash != frozen.query_hash or
|
||||||
|
self._query_variables != frozen.query_variables or
|
||||||
|
self._query_referer != frozen.query_referer or
|
||||||
|
self._context.username != frozen.context_username):
|
||||||
|
raise InvalidArgumentException("Mismatching resume information.")
|
||||||
|
if not frozen.best_before:
|
||||||
|
raise InvalidArgumentException("\"best before\" date missing.")
|
||||||
|
if frozen.remaining_data is None:
|
||||||
|
raise InvalidArgumentException("\"remaining_data\" missing.")
|
||||||
|
self._total_index = frozen.total_index
|
||||||
|
self._best_before = datetime.fromtimestamp(frozen.best_before)
|
||||||
|
self._data = frozen.remaining_data
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def resumable_iteration(context: InstaloaderContext,
|
||||||
|
iterator: Iterable,
|
||||||
|
load: Callable[[InstaloaderContext, str], Any],
|
||||||
|
save: Callable[[FrozenNodeIterator, str], None],
|
||||||
|
format_path: Callable[[str], str],
|
||||||
|
check_bbd: bool = True,
|
||||||
|
enabled: bool = True) -> Iterator[Tuple[bool, int]]:
|
||||||
|
"""
|
||||||
|
High-level context manager to handle a resumable iteration that can be interrupted
|
||||||
|
with a :class:`KeyboardInterrupt` or an :class:`AbortDownloadException`.
|
||||||
|
|
||||||
|
It can be used as follows to automatically load a previously-saved state into the iterator, save the iterator's
|
||||||
|
state when interrupted, and delete the resume file upon completion::
|
||||||
|
|
||||||
|
post_iterator = profile.get_posts()
|
||||||
|
with resumable_iteration(
|
||||||
|
context=L.context,
|
||||||
|
iterator=post_iterator,
|
||||||
|
load=lambda _, path: FrozenNodeIterator(**json.load(open(path))),
|
||||||
|
save=lambda fni, path: json.dump(fni._asdict(), open(path, 'w')),
|
||||||
|
format_path=lambda magic: "resume_info_{}.json".format(magic)
|
||||||
|
) as (is_resuming, start_index):
|
||||||
|
for post in post_iterator:
|
||||||
|
do_something_with(post)
|
||||||
|
|
||||||
|
It yields a tuple (is_resuming, start_index).
|
||||||
|
|
||||||
|
When the passed iterator is not a :class:`NodeIterator`, it behaves as if ``resumable_iteration`` was not used,
|
||||||
|
just executing the inner body.
|
||||||
|
|
||||||
|
:param context: The :class:`InstaloaderContext`.
|
||||||
|
:param iterator: The fresh :class:`NodeIterator`.
|
||||||
|
:param load: Loads a FrozenNodeIterator from given path. The object is ignored if it has a different type.
|
||||||
|
:param save: Saves the given FrozenNodeIterator to the given path.
|
||||||
|
:param format_path: Returns the path to the resume file for the given magic.
|
||||||
|
:param check_bbd: Whether to check the best before date and reject an expired FrozenNodeIterator.
|
||||||
|
:param enabled: Set to False to disable all functionality and simply execute the inner body.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.7
|
||||||
|
Also interrupt on :class:`AbortDownloadException`.
|
||||||
|
"""
|
||||||
|
if not enabled or not isinstance(iterator, NodeIterator):
|
||||||
|
yield False, 0
|
||||||
|
return
|
||||||
|
is_resuming = False
|
||||||
|
start_index = 0
|
||||||
|
resume_file_path = format_path(iterator.magic)
|
||||||
|
resume_file_exists = os.path.isfile(resume_file_path)
|
||||||
|
if resume_file_exists:
|
||||||
|
try:
|
||||||
|
fni = load(context, resume_file_path)
|
||||||
|
if not isinstance(fni, FrozenNodeIterator):
|
||||||
|
raise InvalidArgumentException("Invalid type.")
|
||||||
|
if check_bbd and fni.best_before and datetime.fromtimestamp(fni.best_before) < datetime.now():
|
||||||
|
raise InvalidArgumentException("\"Best before\" date exceeded.")
|
||||||
|
iterator.thaw(fni)
|
||||||
|
is_resuming = True
|
||||||
|
start_index = iterator.total_index
|
||||||
|
context.log("Resuming from {}.".format(resume_file_path))
|
||||||
|
except (InvalidArgumentException, LZMAError, json.decoder.JSONDecodeError) as exc:
|
||||||
|
context.error("Warning: Not resuming from {}: {}".format(resume_file_path, exc))
|
||||||
|
try:
|
||||||
|
yield is_resuming, start_index
|
||||||
|
except (KeyboardInterrupt, AbortDownloadException):
|
||||||
|
if os.path.dirname(resume_file_path):
|
||||||
|
os.makedirs(os.path.dirname(resume_file_path), exist_ok=True)
|
||||||
|
save(iterator.freeze(), resume_file_path)
|
||||||
|
context.log("\nSaved resume information to {}.".format(resume_file_path))
|
||||||
|
raise
|
||||||
|
if resume_file_exists:
|
||||||
|
os.unlink(resume_file_path)
|
||||||
|
context.log("Iteration complete, deleted resume information file {}.".format(resume_file_path))
|
||||||
+226
-91
@@ -4,12 +4,12 @@ import re
|
|||||||
from base64 import b64decode, b64encode
|
from base64 import b64decode, b64encode
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, Iterator, List, Optional, Union
|
from typing import Any, Dict, Iterable, Iterator, List, Optional, Union
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .exceptions import *
|
from .exceptions import *
|
||||||
from .instaloadercontext import InstaloaderContext
|
from .instaloadercontext import InstaloaderContext
|
||||||
|
from .nodeiterator import FrozenNodeIterator, NodeIterator
|
||||||
|
|
||||||
PostSidecarNode = namedtuple('PostSidecarNode', ['is_video', 'display_url', 'video_url'])
|
PostSidecarNode = namedtuple('PostSidecarNode', ['is_video', 'display_url', 'video_url'])
|
||||||
PostSidecarNode.__doc__ = "Item of a Sidecar Post."
|
PostSidecarNode.__doc__ = "Item of a Sidecar Post."
|
||||||
@@ -26,7 +26,7 @@ PostCommentAnswer.likes_count.__doc__ = "Number of likes on comment."
|
|||||||
|
|
||||||
PostComment = namedtuple('PostComment', (*PostCommentAnswer._fields, 'answers')) # type: ignore
|
PostComment = namedtuple('PostComment', (*PostCommentAnswer._fields, 'answers')) # type: ignore
|
||||||
for field in PostCommentAnswer._fields:
|
for field in PostCommentAnswer._fields:
|
||||||
getattr(PostComment, field).__doc__ = getattr(PostCommentAnswer, field).__doc__
|
getattr(PostComment, field).__doc__ = getattr(PostCommentAnswer, field).__doc__ # pylint: disable=no-member
|
||||||
PostComment.answers.__doc__ = r"Iterator which yields all :class:`PostCommentAnswer`\ s for the comment." # type: ignore
|
PostComment.answers.__doc__ = r"Iterator which yields all :class:`PostCommentAnswer`\ s for the comment." # type: ignore
|
||||||
|
|
||||||
PostLocation = namedtuple('PostLocation', ['id', 'name', 'slug', 'has_public_page', 'lat', 'lng'])
|
PostLocation = namedtuple('PostLocation', ['id', 'name', 'slug', 'has_public_page', 'lat', 'lng'])
|
||||||
@@ -128,6 +128,14 @@ class Post:
|
|||||||
"""The mediaid is a decimal representation of the media shortcode."""
|
"""The mediaid is a decimal representation of the media shortcode."""
|
||||||
return int(self._node['id'])
|
return int(self._node['id'])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self) -> Optional[str]:
|
||||||
|
"""Title of post"""
|
||||||
|
try:
|
||||||
|
return self._field('title')
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<Post {}>'.format(self.shortcode)
|
return '<Post {}>'.format(self.shortcode)
|
||||||
|
|
||||||
@@ -147,10 +155,6 @@ class Post:
|
|||||||
)
|
)
|
||||||
self._full_metadata_dict = pic_json['data']['shortcode_media']
|
self._full_metadata_dict = pic_json['data']['shortcode_media']
|
||||||
if self._full_metadata_dict is None:
|
if self._full_metadata_dict is None:
|
||||||
# issue #449
|
|
||||||
self._context.error("Fetching Post metadata failed (issue #449). "
|
|
||||||
"The following data has been returned:\n"
|
|
||||||
+ json.dumps(pic_json['entry_data'], indent=2))
|
|
||||||
raise BadResponseException("Fetching Post metadata failed.")
|
raise BadResponseException("Fetching Post metadata failed.")
|
||||||
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)
|
||||||
@@ -207,7 +211,13 @@ class Post:
|
|||||||
@property
|
@property
|
||||||
def owner_id(self) -> int:
|
def owner_id(self) -> int:
|
||||||
"""The ID of the Post's owner."""
|
"""The ID of the Post's owner."""
|
||||||
return self.owner_profile.userid
|
# The ID may already be available, e.g. if the post instance was created
|
||||||
|
# from an `hashtag.get_posts()` iterator, so no need to make another
|
||||||
|
# http request.
|
||||||
|
if 'owner' in self._node and 'id' in self._node['owner']:
|
||||||
|
return self._node['owner']['id']
|
||||||
|
else:
|
||||||
|
return self.owner_profile.userid
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def date_local(self) -> datetime:
|
def date_local(self) -> datetime:
|
||||||
@@ -250,26 +260,60 @@ class Post:
|
|||||||
"""Type of post, GraphImage, GraphVideo or GraphSidecar"""
|
"""Type of post, GraphImage, GraphVideo or GraphSidecar"""
|
||||||
return self._field('__typename')
|
return self._field('__typename')
|
||||||
|
|
||||||
def get_sidecar_nodes(self) -> Iterator[PostSidecarNode]:
|
@property
|
||||||
"""Sidecar nodes of a Post with typename==GraphSidecar."""
|
def mediacount(self) -> int:
|
||||||
|
"""
|
||||||
|
The number of media in a sidecar Post, or 1 if the Post it not a sidecar.
|
||||||
|
|
||||||
|
.. versionadded:: 4.6
|
||||||
|
"""
|
||||||
if self.typename == 'GraphSidecar':
|
if self.typename == 'GraphSidecar':
|
||||||
edges = self._field('edge_sidecar_to_children', 'edges')
|
edges = self._field('edge_sidecar_to_children', 'edges')
|
||||||
if any(edge['node']['is_video'] for edge in edges):
|
return len(edges)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def get_is_videos(self) -> List[bool]:
|
||||||
|
"""
|
||||||
|
Return a list containing the ``is_video`` property for each media in the post.
|
||||||
|
|
||||||
|
.. versionadded:: 4.7
|
||||||
|
"""
|
||||||
|
if self.typename == 'GraphSidecar':
|
||||||
|
edges = self._field('edge_sidecar_to_children', 'edges')
|
||||||
|
return [edge['node']['is_video'] for edge in edges]
|
||||||
|
return [self.is_video]
|
||||||
|
|
||||||
|
def get_sidecar_nodes(self, start=0, end=-1) -> Iterator[PostSidecarNode]:
|
||||||
|
"""
|
||||||
|
Sidecar nodes of a Post with typename==GraphSidecar.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.6
|
||||||
|
Added parameters *start* and *end* to specify a slice of sidecar media.
|
||||||
|
"""
|
||||||
|
if self.typename == 'GraphSidecar':
|
||||||
|
edges = self._field('edge_sidecar_to_children', 'edges')
|
||||||
|
if end < 0:
|
||||||
|
end = len(edges)-1
|
||||||
|
if start < 0:
|
||||||
|
start = len(edges)-1
|
||||||
|
if any(edge['node']['is_video'] and 'video_url' not in edge['node'] for edge in edges[start:(end+1)]):
|
||||||
# video_url is only present in full metadata, issue #558.
|
# video_url is only present in full metadata, issue #558.
|
||||||
edges = self._full_metadata['edge_sidecar_to_children']['edges']
|
edges = self._full_metadata['edge_sidecar_to_children']['edges']
|
||||||
for idx, edge in enumerate(edges):
|
for idx, edge in enumerate(edges):
|
||||||
node = edge['node']
|
if start <= idx <= end:
|
||||||
is_video = node['is_video']
|
node = edge['node']
|
||||||
display_url = node['display_url']
|
is_video = node['is_video']
|
||||||
if not is_video and self._context.is_logged_in:
|
display_url = node['display_url']
|
||||||
try:
|
if not is_video and self._context.is_logged_in:
|
||||||
carousel_media = self._iphone_struct['carousel_media']
|
try:
|
||||||
orig_url = carousel_media[idx]['image_versions2']['candidates'][0]['url']
|
carousel_media = self._iphone_struct['carousel_media']
|
||||||
display_url = re.sub(r'&se=\d+(&?)', r'\1', orig_url)
|
orig_url = carousel_media[idx]['image_versions2']['candidates'][0]['url']
|
||||||
except (InstaloaderException, KeyError, IndexError) as err:
|
display_url = re.sub(r'&se=\d+(&?)', r'\1', orig_url)
|
||||||
self._context.error('{} Unable to fetch high quality image version of {}.'.format(err, self))
|
except (InstaloaderException, KeyError, IndexError) as err:
|
||||||
yield PostSidecarNode(is_video=is_video, display_url=display_url,
|
self._context.error('{} Unable to fetch high quality image version of {}.'.format(
|
||||||
video_url=node['video_url'] if is_video else None)
|
err, self))
|
||||||
|
yield PostSidecarNode(is_video=is_video, display_url=display_url,
|
||||||
|
video_url=node['video_url'] if is_video else None)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def caption(self) -> Optional[str]:
|
def caption(self) -> Optional[str]:
|
||||||
@@ -328,6 +372,12 @@ class Post:
|
|||||||
def video_url(self) -> Optional[str]:
|
def video_url(self) -> Optional[str]:
|
||||||
"""URL of the video, or None."""
|
"""URL of the video, or None."""
|
||||||
if self.is_video:
|
if self.is_video:
|
||||||
|
if self._context.is_logged_in:
|
||||||
|
try:
|
||||||
|
url = self._iphone_struct['video_versions'][0]['url']
|
||||||
|
return url
|
||||||
|
except (InstaloaderException, KeyError, IndexError) as err:
|
||||||
|
self._context.error('{} Unable to fetch high quality video version of {}.'.format(err, self))
|
||||||
return self._field('video_url')
|
return self._field('video_url')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -376,12 +426,15 @@ 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(self) -> Iterator[PostComment]:
|
def get_comments(self) -> Iterable[PostComment]:
|
||||||
r"""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
|
||||||
|
Change return type to ``Iterable``.
|
||||||
"""
|
"""
|
||||||
def _postcommentanswer(node):
|
def _postcommentanswer(node):
|
||||||
return PostCommentAnswer(id=int(node['id']),
|
return PostCommentAnswer(id=int(node['id']),
|
||||||
@@ -402,35 +455,46 @@ class Post:
|
|||||||
# If the answer's metadata already contains all comments, don't do GraphQL requests to obtain them
|
# If the answer's metadata already contains all comments, don't do GraphQL requests to obtain them
|
||||||
yield from (_postcommentanswer(comment['node']) for comment in answer_edges)
|
yield from (_postcommentanswer(comment['node']) for comment in answer_edges)
|
||||||
return
|
return
|
||||||
yield from (_postcommentanswer(answer_node) for answer_node in
|
yield from NodeIterator(
|
||||||
self._context.graphql_node_list("51fdd02b67508306ad4484ff574a0b62",
|
self._context,
|
||||||
{'comment_id': node['id']},
|
'51fdd02b67508306ad4484ff574a0b62',
|
||||||
'https://www.instagram.com/p/' + self.shortcode + '/',
|
lambda d: d['data']['comment']['edge_threaded_comments'],
|
||||||
lambda d: d['data']['comment']['edge_threaded_comments']))
|
_postcommentanswer,
|
||||||
|
{'comment_id': node['id']},
|
||||||
|
'https://www.instagram.com/p/{0}/'.format(self.shortcode),
|
||||||
|
)
|
||||||
|
|
||||||
def _postcomment(node):
|
def _postcomment(node):
|
||||||
return PostComment(*_postcommentanswer(node),
|
return PostComment(*_postcommentanswer(node),
|
||||||
answers=_postcommentanswers(node))
|
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 []
|
||||||
|
|
||||||
comment_edges = self._field('edge_media_to_comment', 'edges')
|
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
|
||||||
yield from (_postcomment(comment['node']) for comment in comment_edges)
|
return [_postcomment(comment['node']) for comment in comment_edges]
|
||||||
return
|
return NodeIterator(
|
||||||
yield from (_postcomment(node) for node in
|
self._context,
|
||||||
self._context.graphql_node_list(
|
'97b41c52301f77ce508f55e66d17620e',
|
||||||
"97b41c52301f77ce508f55e66d17620e",
|
lambda d: d['data']['shortcode_media']['edge_media_to_parent_comment'],
|
||||||
{'shortcode': self.shortcode},
|
_postcomment,
|
||||||
'https://www.instagram.com/p/' + self.shortcode + '/',
|
{'shortcode': self.shortcode},
|
||||||
lambda d: d['data']['shortcode_media']['edge_media_to_parent_comment']))
|
'https://www.instagram.com/p/{0}/'.format(self.shortcode),
|
||||||
|
)
|
||||||
|
|
||||||
def get_likes(self) -> Iterator['Profile']:
|
def get_likes(self) -> Iterator['Profile']:
|
||||||
"""Iterate over all likes of the post. A :class:`Profile` instance of each likee is yielded."""
|
"""
|
||||||
|
Iterate over all likes of the post. A :class:`Profile` instance of each likee is yielded.
|
||||||
|
|
||||||
|
.. versionchanged:: 4.5.4
|
||||||
|
Require being logged in (as required by Instagram).
|
||||||
|
"""
|
||||||
|
if not self._context.is_logged_in:
|
||||||
|
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
|
||||||
@@ -439,10 +503,14 @@ class Post:
|
|||||||
# If the Post's metadata already contains all likes, don't do GraphQL requests to obtain them
|
# If the Post's metadata already contains all likes, don't do GraphQL requests to obtain them
|
||||||
yield from (Profile(self._context, like['node']) for like in likes_edges)
|
yield from (Profile(self._context, like['node']) for like in likes_edges)
|
||||||
return
|
return
|
||||||
yield from (Profile(self._context, node) for node in
|
yield from NodeIterator(
|
||||||
self._context.graphql_node_list("1cb6ec562846122743b61e492c85999f", {'shortcode': self.shortcode},
|
self._context,
|
||||||
'https://www.instagram.com/p/' + self.shortcode + '/',
|
'1cb6ec562846122743b61e492c85999f',
|
||||||
lambda d: d['data']['shortcode_media']['edge_liked_by']))
|
lambda d: d['data']['shortcode_media']['edge_liked_by'],
|
||||||
|
lambda n: Profile(self._context, n),
|
||||||
|
{'shortcode': self.shortcode},
|
||||||
|
'https://www.instagram.com/p/{0}/'.format(self.shortcode),
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_sponsored(self) -> bool:
|
def is_sponsored(self) -> bool:
|
||||||
@@ -540,7 +608,7 @@ class Profile:
|
|||||||
"""
|
"""
|
||||||
# pylint:disable=protected-access
|
# pylint:disable=protected-access
|
||||||
profile = cls(context, {'username': username.lower()})
|
profile = cls(context, {'username': username.lower()})
|
||||||
profile._obtain_metadata() # to raise ProfileNotExistException now in case username is invalid
|
profile._obtain_metadata() # to raise ProfileNotExistsException now in case username is invalid
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -570,6 +638,17 @@ class Profile:
|
|||||||
context.profile_id_cache[profile_id] = profile
|
context.profile_id_cache[profile_id] = profile
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def own_profile(cls, context: InstaloaderContext):
|
||||||
|
"""Return own profile if logged-in.
|
||||||
|
|
||||||
|
:param context: :attr:`Instaloader.context`
|
||||||
|
|
||||||
|
.. versionadded:: 4.5.2"""
|
||||||
|
if not context.is_logged_in:
|
||||||
|
raise LoginRequiredException("--login required to access own profile.")
|
||||||
|
return cls(context, context.graphql_query("d6f4427fbe92d846298cf93df0b937d3", {})["data"]["user"])
|
||||||
|
|
||||||
def _asdict(self):
|
def _asdict(self):
|
||||||
json_node = self._node.copy()
|
json_node = self._node.copy()
|
||||||
# remove posts to avoid "Circular reference detected" exception
|
# remove posts to avoid "Circular reference detected" exception
|
||||||
@@ -770,80 +849,108 @@ class Profile:
|
|||||||
Use :attr:`profile_pic_url`."""
|
Use :attr:`profile_pic_url`."""
|
||||||
return self.profile_pic_url
|
return self.profile_pic_url
|
||||||
|
|
||||||
def get_posts(self) -> Iterator[Post]:
|
def get_posts(self) -> NodeIterator[Post]:
|
||||||
"""Retrieve all posts from a profile."""
|
"""Retrieve all posts from a profile.
|
||||||
self._obtain_metadata()
|
|
||||||
yield from (Post(self._context, node, self) for node in
|
|
||||||
self._context.graphql_node_list("472f257a40c653c64c666ce877d59d2b",
|
|
||||||
{'id': self.userid},
|
|
||||||
'https://www.instagram.com/{0}/'.format(self.username),
|
|
||||||
lambda d: d['data']['user']['edge_owner_to_timeline_media'],
|
|
||||||
first_data=self._metadata('edge_owner_to_timeline_media')))
|
|
||||||
|
|
||||||
def get_saved_posts(self) -> Iterator[Post]:
|
:rtype: NodeIterator[Post]"""
|
||||||
"""Get Posts that are marked as saved by the user."""
|
self._obtain_metadata()
|
||||||
|
return NodeIterator(
|
||||||
|
self._context,
|
||||||
|
'003056d32c2554def87228bc3fd9668a',
|
||||||
|
lambda d: d['data']['user']['edge_owner_to_timeline_media'],
|
||||||
|
lambda n: Post(self._context, n, self),
|
||||||
|
{'id': self.userid},
|
||||||
|
'https://www.instagram.com/{0}/'.format(self.username),
|
||||||
|
self._metadata('edge_owner_to_timeline_media'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_saved_posts(self) -> NodeIterator[Post]:
|
||||||
|
"""Get Posts that are marked as saved by the user.
|
||||||
|
|
||||||
|
:rtype: NodeIterator[Post]"""
|
||||||
|
|
||||||
if self.username != self._context.username:
|
if self.username != self._context.username:
|
||||||
raise LoginRequiredException("--login={} required to get that profile's saved posts.".format(self.username))
|
raise LoginRequiredException("--login={} required to get that profile's saved posts.".format(self.username))
|
||||||
|
|
||||||
self._obtain_metadata()
|
return NodeIterator(
|
||||||
yield from (Post(self._context, node) for node in
|
self._context,
|
||||||
self._context.graphql_node_list("f883d95537fbcd400f466f63d42bd8a1",
|
'f883d95537fbcd400f466f63d42bd8a1',
|
||||||
{'id': self.userid},
|
lambda d: d['data']['user']['edge_saved_media'],
|
||||||
'https://www.instagram.com/{0}/'.format(self.username),
|
lambda n: Post(self._context, n),
|
||||||
lambda d: d['data']['user']['edge_saved_media'],
|
{'id': self.userid},
|
||||||
first_data=self._metadata('edge_saved_media')))
|
'https://www.instagram.com/{0}/'.format(self.username),
|
||||||
|
)
|
||||||
|
|
||||||
def get_tagged_posts(self) -> Iterator[Post]:
|
def get_tagged_posts(self) -> NodeIterator[Post]:
|
||||||
"""Retrieve all posts where a profile is tagged.
|
"""Retrieve all posts where a profile is tagged.
|
||||||
|
|
||||||
|
:rtype: NodeIterator[Post]
|
||||||
|
|
||||||
.. versionadded:: 4.0.7"""
|
.. versionadded:: 4.0.7"""
|
||||||
self._obtain_metadata()
|
self._obtain_metadata()
|
||||||
yield from (Post(self._context, node, self if int(node['owner']['id']) == self.userid else None) for node in
|
return NodeIterator(
|
||||||
self._context.graphql_node_list("e31a871f7301132ceaab56507a66bbb7",
|
self._context,
|
||||||
{'id': self.userid},
|
'e31a871f7301132ceaab56507a66bbb7',
|
||||||
'https://www.instagram.com/{0}/'.format(self.username),
|
lambda d: d['data']['user']['edge_user_to_photos_of_you'],
|
||||||
lambda d: d['data']['user']['edge_user_to_photos_of_you']))
|
lambda n: Post(self._context, n, self if int(n['owner']['id']) == self.userid else None),
|
||||||
|
{'id': self.userid},
|
||||||
|
'https://www.instagram.com/{0}/'.format(self.username),
|
||||||
|
)
|
||||||
|
|
||||||
def get_igtv_posts(self) -> Iterator[Post]:
|
def get_igtv_posts(self) -> NodeIterator[Post]:
|
||||||
"""Retrieve all IGTV posts.
|
"""Retrieve all IGTV posts.
|
||||||
|
|
||||||
|
:rtype: NodeIterator[Post]
|
||||||
|
|
||||||
.. versionadded:: 4.3"""
|
.. versionadded:: 4.3"""
|
||||||
self._obtain_metadata()
|
self._obtain_metadata()
|
||||||
yield from (Post(self._context, node, self) for node in
|
return NodeIterator(
|
||||||
self._context.graphql_node_list('bc78b344a68ed16dd5d7f264681c4c76',
|
self._context,
|
||||||
{'id': self.userid},
|
'bc78b344a68ed16dd5d7f264681c4c76',
|
||||||
'https://www.instagram.com/{0}/channel/'.format(self.username),
|
lambda d: d['data']['user']['edge_felix_video_timeline'],
|
||||||
lambda d: d['data']['user']['edge_felix_video_timeline'],
|
lambda n: Post(self._context, n, self),
|
||||||
first_data=self._metadata('edge_felix_video_timeline')))
|
{'id': self.userid},
|
||||||
|
'https://www.instagram.com/{0}/channel/'.format(self.username),
|
||||||
|
self._metadata('edge_felix_video_timeline'),
|
||||||
|
)
|
||||||
|
|
||||||
def get_followers(self) -> Iterator['Profile']:
|
def get_followers(self) -> NodeIterator['Profile']:
|
||||||
"""
|
"""
|
||||||
Retrieve list of followers of given profile.
|
Retrieve list of followers of given profile.
|
||||||
To use this, one needs to be logged in and private profiles has to be followed.
|
To use this, one needs to be logged in and private profiles has to be followed.
|
||||||
|
|
||||||
|
: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()
|
||||||
yield from (Profile(self._context, node) for node in
|
return NodeIterator(
|
||||||
self._context.graphql_node_list("37479f2b8209594dde7facb0d904896a",
|
self._context,
|
||||||
{'id': str(self.userid)},
|
'37479f2b8209594dde7facb0d904896a',
|
||||||
'https://www.instagram.com/' + self.username + '/',
|
lambda d: d['data']['user']['edge_followed_by'],
|
||||||
lambda d: d['data']['user']['edge_followed_by']))
|
lambda n: Profile(self._context, n),
|
||||||
|
{'id': str(self.userid)},
|
||||||
|
'https://www.instagram.com/{0}/'.format(self.username),
|
||||||
|
)
|
||||||
|
|
||||||
def get_followees(self) -> Iterator['Profile']:
|
def get_followees(self) -> NodeIterator['Profile']:
|
||||||
"""
|
"""
|
||||||
Retrieve list of followees (followings) of given profile.
|
Retrieve list of followees (followings) of given profile.
|
||||||
To use this, one needs to be logged in and private profiles has to be followed.
|
To use this, one needs to be logged in and private profiles has to be followed.
|
||||||
|
|
||||||
|
: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()
|
||||||
yield from (Profile(self._context, node) for node in
|
return NodeIterator(
|
||||||
self._context.graphql_node_list("58712303d941c6855d4e888c5f0cd22f",
|
self._context,
|
||||||
{'id': str(self.userid)},
|
'58712303d941c6855d4e888c5f0cd22f',
|
||||||
'https://www.instagram.com/' + self.username + '/',
|
lambda d: d['data']['user']['edge_follow'],
|
||||||
lambda d: d['data']['user']['edge_follow']))
|
lambda n: Profile(self._context, n),
|
||||||
|
{'id': str(self.userid)},
|
||||||
|
'https://www.instagram.com/{0}/'.format(self.username),
|
||||||
|
)
|
||||||
|
|
||||||
def get_similar_accounts(self) -> Iterator['Profile']:
|
def get_similar_accounts(self) -> Iterator['Profile']:
|
||||||
"""
|
"""
|
||||||
@@ -877,11 +984,17 @@ class StoryItem:
|
|||||||
self._context = context
|
self._context = context
|
||||||
self._node = node
|
self._node = node
|
||||||
self._owner_profile = owner_profile
|
self._owner_profile = owner_profile
|
||||||
|
self._iphone_struct_ = None
|
||||||
|
if 'iphone_struct' in node:
|
||||||
|
# if loaded from JSON with load_structure_from_file()
|
||||||
|
self._iphone_struct_ = node['iphone_struct']
|
||||||
|
|
||||||
def _asdict(self):
|
def _asdict(self):
|
||||||
node = self._node
|
node = self._node
|
||||||
if self._owner_profile:
|
if self._owner_profile:
|
||||||
node['owner'] = self._owner_profile._asdict()
|
node['owner'] = self._owner_profile._asdict()
|
||||||
|
if self._iphone_struct_:
|
||||||
|
node['iphone_struct'] = self._iphone_struct_
|
||||||
return node
|
return node
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -906,6 +1019,15 @@ class StoryItem:
|
|||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
return hash(self.mediaid)
|
return hash(self.mediaid)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _iphone_struct(self) -> Dict[str, Any]:
|
||||||
|
if not self._context.is_logged_in:
|
||||||
|
raise LoginRequiredException("--login required to access iPhone media info endpoint.")
|
||||||
|
if not self._iphone_struct_:
|
||||||
|
data = self._context.get_iphone_json(path='api/v1/media/{}/info/'.format(self.mediaid), params={})
|
||||||
|
self._iphone_struct_ = data['items'][0]
|
||||||
|
return self._iphone_struct_
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def owner_profile(self) -> Profile:
|
def owner_profile(self) -> Profile:
|
||||||
""":class:`Profile` instance of the story item's owner."""
|
""":class:`Profile` instance of the story item's owner."""
|
||||||
@@ -957,6 +1079,13 @@ class StoryItem:
|
|||||||
@property
|
@property
|
||||||
def url(self) -> str:
|
def url(self) -> str:
|
||||||
"""URL of the picture / video thumbnail of the StoryItem"""
|
"""URL of the picture / video thumbnail of the StoryItem"""
|
||||||
|
if self.typename == "GraphStoryImage" and self._context.is_logged_in:
|
||||||
|
try:
|
||||||
|
orig_url = self._iphone_struct['image_versions2']['candidates'][0]['url']
|
||||||
|
url = re.sub(r'&se=\d+(&?)', r'\1', orig_url)
|
||||||
|
return url
|
||||||
|
except (InstaloaderException, KeyError, IndexError) as err:
|
||||||
|
self._context.error('{} Unable to fetch high quality image version of {}.'.format(err, self))
|
||||||
return self._node['display_resources'][-1]['src']
|
return self._node['display_resources'][-1]['src']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -1029,14 +1158,14 @@ class Story:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def last_seen_local(self) -> Optional[datetime]:
|
def last_seen_local(self) -> Optional[datetime]:
|
||||||
"""Timestamp when the story has last been watched or None (local time zone)."""
|
"""Timestamp of the most recent StoryItem that has been watched or None (local time zone)."""
|
||||||
if self._node['seen']:
|
if self._node['seen']:
|
||||||
return datetime.fromtimestamp(self._node['seen'])
|
return datetime.fromtimestamp(self._node['seen'])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def last_seen_utc(self) -> Optional[datetime]:
|
def last_seen_utc(self) -> Optional[datetime]:
|
||||||
"""Timestamp when the story has last been watched or None (UTC)."""
|
"""Timestamp of the most recent StoryItem that has been watched or None (UTC)."""
|
||||||
if self._node['seen']:
|
if self._node['seen']:
|
||||||
return datetime.utcfromtimestamp(self._node['seen'])
|
return datetime.utcfromtimestamp(self._node['seen'])
|
||||||
return None
|
return None
|
||||||
@@ -1295,16 +1424,20 @@ class Hashtag:
|
|||||||
yield from (Post(self._context, edge["node"]) for edge in conn["edges"])
|
yield from (Post(self._context, edge["node"]) for edge in conn["edges"])
|
||||||
|
|
||||||
def get_all_posts(self) -> Iterator[Post]:
|
def get_all_posts(self) -> Iterator[Post]:
|
||||||
"""Yields all posts, i.e. all most recent posts and the top posts, in chronological order."""
|
"""Yields all posts, i.e. all most recent posts and the top posts, in almost-chronological order."""
|
||||||
sorted_top_posts = iter(sorted(self.get_top_posts(), key=lambda p: p.date_utc, reverse=True))
|
sorted_top_posts = iter(sorted(self.get_top_posts(), key=lambda p: p.date_utc, reverse=True))
|
||||||
other_posts = self.get_posts()
|
other_posts = self.get_posts()
|
||||||
next_top = next(sorted_top_posts, None)
|
next_top = next(sorted_top_posts, None)
|
||||||
next_other = next(other_posts, None)
|
next_other = next(other_posts, None)
|
||||||
while next_top is not None or next_other is not None:
|
while next_top is not None or next_other is not None:
|
||||||
if next_other is None:
|
if next_other is None:
|
||||||
|
assert next_top is not None
|
||||||
|
yield next_top
|
||||||
yield from sorted_top_posts
|
yield from sorted_top_posts
|
||||||
break
|
break
|
||||||
if next_top is None:
|
if next_top is None:
|
||||||
|
assert next_other is not None
|
||||||
|
yield next_other
|
||||||
yield from other_posts
|
yield from other_posts
|
||||||
break
|
break
|
||||||
if next_top == next_other:
|
if next_top == next_other:
|
||||||
@@ -1398,7 +1531,7 @@ class TopSearchResults:
|
|||||||
return self._searchstring
|
return self._searchstring
|
||||||
|
|
||||||
|
|
||||||
JsonExportable = Union[Post, Profile, StoryItem, Hashtag]
|
JsonExportable = Union[Post, Profile, StoryItem, Hashtag, FrozenNodeIterator]
|
||||||
|
|
||||||
|
|
||||||
def save_structure_to_file(structure: JsonExportable, filename: str) -> None:
|
def save_structure_to_file(structure: JsonExportable, filename: str) -> None:
|
||||||
@@ -1447,6 +1580,8 @@ def load_structure_from_file(context: InstaloaderContext, filename: str) -> Json
|
|||||||
return StoryItem(context, json_structure['node'])
|
return StoryItem(context, json_structure['node'])
|
||||||
elif node_type == "Hashtag":
|
elif node_type == "Hashtag":
|
||||||
return Hashtag(context, json_structure['node'])
|
return Hashtag(context, json_structure['node'])
|
||||||
|
elif node_type == "FrozenNodeIterator":
|
||||||
|
return FrozenNodeIterator(**json_structure['node'])
|
||||||
else:
|
else:
|
||||||
raise InvalidArgumentException("{}: Not an Instaloader JSON.".format(filename))
|
raise InvalidArgumentException("{}: Not an Instaloader JSON.".format(filename))
|
||||||
elif 'shortcode' in json_structure:
|
elif 'shortcode' in json_structure:
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import platform
|
|
||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
|
|
||||||
@@ -19,14 +18,11 @@ def get_version():
|
|||||||
raise SystemExit("Could not find version string.")
|
raise SystemExit("Could not find version string.")
|
||||||
|
|
||||||
|
|
||||||
if sys.version_info < (3, 5):
|
if sys.version_info < (3, 6):
|
||||||
sys.exit('Instaloader requires Python >= 3.5.')
|
sys.exit('Instaloader requires Python >= 3.6.')
|
||||||
|
|
||||||
requirements = ['requests>=2.4']
|
requirements = ['requests>=2.4']
|
||||||
|
|
||||||
if platform.system() == 'Windows' and sys.version_info < (3, 6):
|
|
||||||
requirements.append('win_unicode_console')
|
|
||||||
|
|
||||||
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',
|
||||||
'instagram-stories'])
|
'instagram-stories'])
|
||||||
@@ -38,6 +34,7 @@ setup(
|
|||||||
name='instaloader',
|
name='instaloader',
|
||||||
version=get_version(),
|
version=get_version(),
|
||||||
packages=['instaloader'],
|
packages=['instaloader'],
|
||||||
|
package_data={'instaloader': ['py.typed']},
|
||||||
url='https://instaloader.github.io/',
|
url='https://instaloader.github.io/',
|
||||||
license='MIT',
|
license='MIT',
|
||||||
author='Alexander Graf, André Koch-Kramer',
|
author='Alexander Graf, André Koch-Kramer',
|
||||||
@@ -46,9 +43,9 @@ 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.5',
|
python_requires='>=3.6',
|
||||||
entry_points={'console_scripts': ['instaloader=instaloader.__main__:main']},
|
entry_points={'console_scripts': ['instaloader=instaloader.__main__:main']},
|
||||||
zip_safe=True,
|
zip_safe=False,
|
||||||
keywords=keywords,
|
keywords=keywords,
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Development Status :: 5 - Production/Stable',
|
'Development Status :: 5 - Production/Stable',
|
||||||
@@ -57,10 +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.5',
|
|
||||||
'Programming Language :: Python :: 3.6',
|
'Programming Language :: Python :: 3.6',
|
||||||
'Programming Language :: Python :: 3.7',
|
'Programming Language :: Python :: 3.7',
|
||||||
'Programming Language :: Python :: 3.8',
|
'Programming Language :: Python :: 3.8',
|
||||||
|
'Programming Language :: Python :: 3.9',
|
||||||
'Programming Language :: Python :: 3 :: Only',
|
'Programming Language :: Python :: 3 :: Only',
|
||||||
'Topic :: Internet',
|
'Topic :: Internet',
|
||||||
'Topic :: Multimedia :: Graphics'
|
'Topic :: Multimedia :: Graphics'
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import shutil
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from itertools import islice
|
from itertools import islice
|
||||||
from typing import Dict, List
|
from typing import Optional
|
||||||
|
|
||||||
import instaloader
|
import instaloader
|
||||||
|
|
||||||
@@ -23,8 +23,7 @@ PRIVATE_PROFILE_ID = 1706625676
|
|||||||
EMPTY_PROFILE = "not_public"
|
EMPTY_PROFILE = "not_public"
|
||||||
EMPTY_PROFILE_ID = 1928659031
|
EMPTY_PROFILE_ID = 1928659031
|
||||||
|
|
||||||
# Preserve query timestamps (rate control) between tests to not get rate limited
|
ratecontroller = None # type: Optional[instaloader.RateController]
|
||||||
instaloadercontext_query_timestamps = dict() # type: Dict[str, List[float]]
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstaloaderAnonymously(unittest.TestCase):
|
class TestInstaloaderAnonymously(unittest.TestCase):
|
||||||
@@ -37,13 +36,15 @@ class TestInstaloaderAnonymously(unittest.TestCase):
|
|||||||
download_comments=True,
|
download_comments=True,
|
||||||
save_metadata=True)
|
save_metadata=True)
|
||||||
self.L.context.raise_all_errors = True
|
self.L.context.raise_all_errors = True
|
||||||
# pylint:disable=protected-access
|
if ratecontroller is not None:
|
||||||
self.L.context._graphql_query_timestamps = instaloadercontext_query_timestamps.copy()
|
# pylint:disable=protected-access
|
||||||
|
ratecontroller._context = self.L.context
|
||||||
|
self.L.context._rate_controller = ratecontroller
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
# pylint:disable=global-statement,protected-access
|
# pylint:disable=global-statement,protected-access
|
||||||
global instaloadercontext_query_timestamps
|
global ratecontroller
|
||||||
instaloadercontext_query_timestamps = self.L.context._graphql_query_timestamps.copy()
|
ratecontroller = self.L.context._rate_controller
|
||||||
self.L.close()
|
self.L.close()
|
||||||
os.chdir('/')
|
os.chdir('/')
|
||||||
print("Removing {}".format(self.dir))
|
print("Removing {}".format(self.dir))
|
||||||
|
|||||||
Reference in New Issue
Block a user