mirror of
https://github.com/instaloader/instaloader.git
synced 2026-10-16 21:52:17 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdcf2e1367 | ||
|
|
ca78fee307 | ||
|
|
d6e5e31005 | ||
|
|
d864ce08ff | ||
|
|
555c86633c | ||
|
|
21365ec668 | ||
|
|
6343e9a69d | ||
|
|
06574eb428 | ||
|
|
815ba83d77 | ||
|
|
470fc0d905 | ||
|
|
0300d8a4f1 | ||
|
|
f09bd51ed4 | ||
|
|
2c116683c2 | ||
|
|
054fa47245 | ||
|
|
1f8ebe09b4 | ||
|
|
87dfdff335 | ||
|
|
631503d463 | ||
|
|
43c9d32b22 | ||
|
|
1ad9111753 | ||
|
|
327fcfd8e8 | ||
|
|
ae39ab9893 | ||
|
|
0372f7c8e8 | ||
|
|
aaeaaa0e22 | ||
|
|
35bd8357d1 | ||
|
|
9cdf679fc1 | ||
|
|
94d599db61 | ||
|
|
a0e3e8db0d | ||
|
|
26c9f71132 | ||
|
|
210719af56 | ||
|
|
8c1129e556 | ||
|
|
c200031be6 | ||
|
|
a0d2ec95b9 | ||
|
|
750dd3f47f | ||
|
|
839b77eef0 | ||
|
|
e346842f7b | ||
|
|
9b3f809a9a | ||
|
|
803440ac50 | ||
|
|
455a757159 | ||
|
|
07de73bde1 | ||
|
|
bf83d406d3 | ||
|
|
ac5d6e312a | ||
|
|
d142fb70b0 | ||
|
|
5345470ebf | ||
|
|
a2715cbe03 | ||
|
|
fa19b95107 | ||
|
|
3837b642b0 |
@@ -6,13 +6,15 @@ labels: "feature suggestion"
|
||||
---
|
||||
|
||||
**Provide us a use case of the feature**
|
||||
How could the user invoke the new function? Which problem would it solve? If new information is obtained, how would it be further processed?
|
||||
How could the user invoke the new function? Which problem would it solve?
|
||||
If new information is obtained, how would it be further processed?
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
How do you achieve the here-described goal without the feature you are requesting?
|
||||
|
||||
**If the feature request is accepted, would you be willing to submit a pull request?**
|
||||
Yes / No
|
||||
@@ -21,4 +23,9 @@ Yes / No
|
||||
**Additional context**
|
||||
Add any other context about the feature request here.
|
||||
|
||||
<!-- please also see https://instaloader.github.io/contributing.html for how to suggest a feature -->
|
||||
<!--
|
||||
Also see https://instaloader.github.io/contributing.html for how to suggest a feature.
|
||||
|
||||
Please understand that a feature suggestion issue that lacks the required information cannot be
|
||||
processed.
|
||||
-->
|
||||
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
custom: "https://instaloader.github.io/contributing.html#donations"
|
||||
github: [aandergr]
|
||||
custom: "https://instaloader.github.io/sponsors.html"
|
||||
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.8, 3.9]
|
||||
python-version: ["3.8", "3.9", "3.10"]
|
||||
steps:
|
||||
- name: Checkout Instaloader Repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
@@ -30,5 +30,5 @@ jobs:
|
||||
stale-pr-label: 'stale'
|
||||
exempt-issue-label: 'leave open'
|
||||
exempt-pr-label: 'leave open'
|
||||
days-before-stale: 90
|
||||
days-before-stale: 135
|
||||
days-before-close: 14
|
||||
|
||||
@@ -10,9 +10,9 @@ jobs:
|
||||
- name: Checkout Instaloader repository
|
||||
uses: actions/checkout@v2
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: "3.9"
|
||||
architecture: x64
|
||||
- name: Get the tagged version
|
||||
id: get_version
|
||||
|
||||
+38
-18
@@ -86,6 +86,16 @@ To later **update your local copy** of that profiles, you may run
|
||||
If ``--fast-update`` is given, Instaloader stops when arriving at the
|
||||
first already-downloaded picture.
|
||||
|
||||
Alternatively, you can use ``--latest-stamps`` to have Instaloader store
|
||||
the time each profile was last downloaded and only download newer media:
|
||||
|
||||
::
|
||||
|
||||
instaloader --latest-stamps -- profile [profile ...]
|
||||
|
||||
With this option it's possible to move or delete downloaded media and still keep
|
||||
the archive updated.
|
||||
|
||||
When updating profiles, Instaloader
|
||||
automatically **detects profile name changes** and renames the target directory
|
||||
accordingly.
|
||||
@@ -104,6 +114,34 @@ already have a valid session cookie file.
|
||||
|
||||
`Instaloader Documentation <https://instaloader.github.io/basic-usage.html>`__
|
||||
|
||||
Contributing
|
||||
------------
|
||||
|
||||
As an open source project, Instaloader heavily depends on the contributions from
|
||||
its community. See
|
||||
`contributing <https://instaloader.github.io/contributing.html>`__
|
||||
for how you may help Instaloader to become an even greater tool.
|
||||
|
||||
Supporters
|
||||
----------
|
||||
|
||||
.. current-sponsors-start
|
||||
|
||||
See `Alex' GitHub Sponsors <https://github.com/sponsors/aandergr>`__ page for
|
||||
how you can sponsor the development of Instaloader!
|
||||
|
||||
.. current-sponsors-end
|
||||
|
||||
It is a pleasure for us to share our Instaloader to the world, and we are proud
|
||||
to have attracted such an active and motivating community, with so many users
|
||||
who share their suggestions and ideas with us. Buying a community-sponsored beer
|
||||
or coffee from time to time is very likely to further raise our passion for the
|
||||
development of Instaloader.
|
||||
|
||||
| For Donations, we provide GitHub Sponsors page, a PayPal.Me link and a Bitcoin address.
|
||||
| GitHub Sponsors: `Sponsor @aandergr on GitHub Sponsors <https://github.com/sponsors/aandergr>`__
|
||||
| PayPal: `PayPal.me/aandergr <https://www.paypal.me/aandergr>`__
|
||||
| BTC: 1Nst4LoadeYzrKjJ1DX9CpbLXBYE9RKLwY
|
||||
|
||||
Disclaimer
|
||||
----------
|
||||
@@ -116,21 +154,3 @@ subsidiaries. This is an independent and unofficial project. Use at your own ris
|
||||
Instaloader is licensed under an MIT license. Refer to ``LICENSE`` file for more information.
|
||||
|
||||
.. disclaimer-end
|
||||
|
||||
Contributing
|
||||
------------
|
||||
|
||||
As an open source project, Instaloader heavily depends on the contributions from
|
||||
its community. See
|
||||
`contributing <https://instaloader.github.io/contributing.html>`__
|
||||
for how you may help Instaloader to become an even greater tool.
|
||||
|
||||
It is a pleasure for us to share our Instaloader to the world, and we are proud
|
||||
to have attracted such an active and motivating community, with so many users
|
||||
who share their suggestions and ideas with us. Buying a community-sponsored beer
|
||||
or coffee from time to time is very likely to further raise our passion for the
|
||||
development of Instaloader.
|
||||
|
||||
| For Donations, we provide a PayPal.Me link and a Bitcoin address.
|
||||
| PayPal: `PayPal.me/aandergr <https://www.paypal.me/aandergr>`__
|
||||
| BTC: 1Nst4LoadeYzrKjJ1DX9CpbLXBYE9RKLwY
|
||||
|
||||
@@ -45,7 +45,7 @@ with open('__main__.py', 'w+') as f:
|
||||
|
||||
# install dependencies and invoke PyInstaller
|
||||
commands = ["pip install pipenv==2020.11.15",
|
||||
"pipenv sync --dev",
|
||||
f"pipenv --python {sys.version_info.major}.{sys.version_info.minor} sync --dev",
|
||||
"pipenv run pyinstaller --log-level=DEBUG instaloader.spec"]
|
||||
|
||||
for command in commands:
|
||||
|
||||
+31
-7
@@ -39,6 +39,16 @@ To later **update your local copy** of that profiles, you may run
|
||||
If :option:`--fast-update` is given, Instaloader stops when arriving at the
|
||||
first already-downloaded picture.
|
||||
|
||||
Alternatively, you can use :option:`--latest-stamps` to have Instaloader store
|
||||
the time each profile was last downloaded and only download newer media:
|
||||
|
||||
::
|
||||
|
||||
instaloader --latest-stamps -- profile [profile ...]
|
||||
|
||||
With this option it's possible to move or delete downloaded media and still keep
|
||||
the archive updated.
|
||||
|
||||
When updating profiles, Instaloader
|
||||
automatically **detects profile name changes** and renames the target directory
|
||||
accordingly.
|
||||
@@ -149,27 +159,37 @@ target. The default is ``--dirname-pattern={target}``. In the dirname
|
||||
pattern, the token ``{target}`` is replaced by the target name, and
|
||||
``{profile}`` is replaced by the owner of the post which is downloaded.
|
||||
|
||||
:option:`--filename-pattern` configures the path of the post's files relative
|
||||
:option:`--filename-pattern` configures the path of the post and story's files relative
|
||||
to the target directory that is specified with :option:`--dirname-pattern`.
|
||||
The default is ``--filename-pattern={date_utc}_UTC``.
|
||||
The tokens ``{target}`` and ``{profile}`` are replaced like in the
|
||||
dirname pattern. The following tokens are defined for usage with
|
||||
:option:`--filename-pattern`:
|
||||
dirname pattern.
|
||||
|
||||
:option:`--title-pattern` is similar to :option:`--filename-pattern`, but for profile
|
||||
pics, hashtag profile pics, and highlight covers. The default is
|
||||
``{date_utc}_UTC_{typename}`` if :option:`--dirname-pattern` contains ``{target}`` or
|
||||
``{profile}``, or ``{target}_{date_utc}_UTC_{typename}`` if it does not. Some tokens
|
||||
are not supported for this option, see below for details.
|
||||
|
||||
The following tokens are defined for usage with
|
||||
:option:`--filename-pattern` and :option:`--title-pattern`:
|
||||
|
||||
- ``{target}``
|
||||
Target name (as given in Instaloader command line)
|
||||
|
||||
- ``{profile}`` (same as ``{owner_username}``)
|
||||
Owner of the Post / StoryItem.
|
||||
Owner of the Post / StoryItem / ProfilePic. For hashtag profile pics and
|
||||
highlight covers, equivalent to ``{target}``.
|
||||
|
||||
- ``{owner_id}``
|
||||
Unique integer ID of owner profile.
|
||||
Unique integer ID of owner profile. For hashtag profile pics, equivalent to
|
||||
``{target}``.
|
||||
|
||||
- ``{shortcode}``
|
||||
Shortcode (identifier string).
|
||||
Shortcode (identifier string). Not available for :option:`--title-pattern`.
|
||||
|
||||
- ``{mediaid}``
|
||||
Integer representation of shortcode.
|
||||
Integer representation of shortcode. Not available for :option:`--title-pattern`.
|
||||
|
||||
- ``{filename}``
|
||||
Instagram's internal filename.
|
||||
@@ -182,6 +202,10 @@ dirname pattern. The following tokens are defined for usage with
|
||||
|
||||
{date_utc:%Y-%m-%d_%H-%M-%S}
|
||||
|
||||
- ``{typename}``
|
||||
Type of media being saved, such as GraphImage, GraphStoryVideo, profile_pic,
|
||||
etc.
|
||||
|
||||
For example, encode the poster's profile name in the filenames with::
|
||||
|
||||
instaloader --filename-pattern={date_utc}_UTC_{profile} "#hashtag"
|
||||
|
||||
+35
-2
@@ -100,7 +100,6 @@ What to Download of each Post
|
||||
|
||||
Do not xz compress JSON files, rather create pretty formatted JSONs.
|
||||
|
||||
|
||||
What to Download of each Profile
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
@@ -147,6 +146,23 @@ Which Posts to Download
|
||||
This flag is recommended when you use Instaloader to update your personal
|
||||
Instagram archive.
|
||||
|
||||
.. option:: --latest-stamps [STAMPSFILE]
|
||||
|
||||
Works similarly to :option:`--fast-update`, but instead of relying on already
|
||||
downloaded media, the time each profile was downloaded is stored, and only
|
||||
media newer than the last download is fetched. This allows updating your
|
||||
personal Instagram archive while emptying the target directories.
|
||||
|
||||
Only works for media associated with a specific profile, and that is returned
|
||||
in chronological order: profile posts, profile stories, profile IGTV posts
|
||||
and profile tagged posts.
|
||||
|
||||
By default, the information is stored in
|
||||
``~/.config/instaloader/latest-stamps.ini``, but you can specify an
|
||||
alternative location.
|
||||
|
||||
.. versionadded:: 4.8
|
||||
|
||||
.. option:: --post-filter filter, --only-if filter
|
||||
|
||||
Expression that, if given, must evaluate to True for each post to be
|
||||
@@ -206,12 +222,23 @@ How to Download
|
||||
|
||||
.. option:: --filename-pattern FILENAME_PATTERN
|
||||
|
||||
Prefix of filenames, relative to the directory given with
|
||||
Prefix of filenames for posts and stories, relative to the directory given with
|
||||
:option:`--dirname-pattern`. ``{profile}`` is replaced by the profile name,
|
||||
``{target}`` is replaced by the target you specified, i.e. either ``:feed``,
|
||||
``#hashtag`` or the profile name. Defaults to ``{date_utc}_UTC``.
|
||||
See :ref:`filename-specification` for a list of supported tokens.
|
||||
|
||||
.. option:: --title-pattern TITLE_PATTERN
|
||||
|
||||
Prefix of filenames for profile pics, hashtag profile pics, and highlight
|
||||
covers, relative to the directory given with :option:`--dirname-pattern`.
|
||||
Defaults to ``{date_utc}_UTC_{typename}`` if :option:`--dirname-pattern`
|
||||
contains ``{target}`` or ``{profile}``, otherwise defaults to
|
||||
``{target}_{date_utc}_UTC_{typename}``.
|
||||
See :ref:`filename-specification` for a list of supported tokens.
|
||||
|
||||
.. versionadded:: 4.8
|
||||
|
||||
.. option:: --resume-prefix prefix
|
||||
|
||||
For many targets, Instaloader is capable of resuming a previously-aborted
|
||||
@@ -274,6 +301,12 @@ How to Download
|
||||
|
||||
.. versionadded:: 4.7
|
||||
|
||||
.. option:: --no-iphone
|
||||
|
||||
Do not attempt to download iPhone version of images and videos.
|
||||
|
||||
.. versionadded:: 4.8
|
||||
|
||||
Miscellaneous Options
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
|
||||
+5
-17
@@ -132,21 +132,9 @@ details. If you feel the need to do so, please create a "feature suggestion".
|
||||
be implemented and the underlying problem could be solved. Also **describe
|
||||
alternatives** that you have considered.
|
||||
|
||||
Donations
|
||||
---------
|
||||
Sponsoring
|
||||
----------
|
||||
|
||||
.. donations-start
|
||||
|
||||
It is a pleasure for us to share our Instaloader to the world, and we are proud
|
||||
to have attracted such an active and motivating community, with so many users
|
||||
who share their suggestions and ideas with us. Buying a community-sponsored beer
|
||||
or coffee from time to time is very likely to further raise our passion for the
|
||||
development of Instaloader.
|
||||
|
||||
| For donations, we provide a PayPal.Me link and a Bitcoin address.
|
||||
| `PayPal.me/aandergr <https://www.paypal.me/aandergr>`__
|
||||
| BTC: 1Nst4LoadeYzrKjJ1DX9CpbLXBYE9RKLwY
|
||||
|
||||
.. donations-end
|
||||
|
||||
.. (Discussion in :issue:`130`)
|
||||
.. include:: sponsors.rst
|
||||
:start-after: donations-start
|
||||
:end-before: donations-end
|
||||
|
||||
+11
-8
@@ -78,13 +78,6 @@ Useful Links
|
||||
- `Issue Tracker / Bug Tracker <https://github.com/instaloader/instaloader/issues>`__
|
||||
- `Version History <https://github.com/instaloader/instaloader/releases>`__
|
||||
|
||||
Disclaimer
|
||||
----------
|
||||
|
||||
.. include:: ../README.rst
|
||||
:start-after: disclaimer-start
|
||||
:end-before: disclaimer-end
|
||||
|
||||
Contributing
|
||||
------------
|
||||
|
||||
@@ -92,10 +85,20 @@ As an open source project, Instaloader heavily depends on the contributions from
|
||||
its community. See :ref:`contributing` for how you may help Instaloader to
|
||||
become an even greater tool.
|
||||
|
||||
.. include:: contributing.rst
|
||||
Supporters
|
||||
----------
|
||||
|
||||
.. include:: sponsors.rst
|
||||
:start-after: donations-start
|
||||
:end-before: donations-end
|
||||
|
||||
Disclaimer
|
||||
----------
|
||||
|
||||
.. include:: ../README.rst
|
||||
:start-after: disclaimer-start
|
||||
:end-before: disclaimer-end
|
||||
|
||||
..
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
|
||||
@@ -77,6 +77,16 @@ 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
|
||||
|
||||
.. autofunction:: load_structure_from_file
|
||||
|
||||
.. autofunction:: get_json_structure
|
||||
|
||||
.. autofunction:: save_structure_to_file
|
||||
|
||||
LatestStamps
|
||||
""""""""""""
|
||||
|
||||
.. autoclass:: LatestStamps
|
||||
:no-show-inheritance:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
.. _sponsors:
|
||||
|
||||
Sponsoring the development of Instaloader
|
||||
=========================================
|
||||
|
||||
.. donations-start
|
||||
|
||||
.. include:: ../README.rst
|
||||
:start-after: current-sponsors-start
|
||||
:end-before: current-sponsors-end
|
||||
|
||||
It is a pleasure for us to share our Instaloader to the world, and we are proud
|
||||
to have attracted such an active and motivating community, with so many users
|
||||
who share their suggestions and ideas with us. Buying a community-sponsored beer
|
||||
or coffee from time to time is very likely to further raise our passion for the
|
||||
development of Instaloader.
|
||||
|
||||
| For Donations, we provide GitHub Sponsors page, a PayPal.Me link and a Bitcoin address.
|
||||
| GitHub Sponsors: `Sponsor @aandergr on GitHub Sponsors <https://github.com/sponsors/aandergr>`__
|
||||
| PayPal: `PayPal.me/aandergr <https://www.paypal.me/aandergr>`__
|
||||
| BTC: 1Nst4LoadeYzrKjJ1DX9CpbLXBYE9RKLwY
|
||||
|
||||
.. donations-end
|
||||
@@ -62,7 +62,8 @@ Two-Factor-Authentication. It also supports handling the *checkpoint challenge*,
|
||||
issued when Instagram suspects authentication activity on your account, by
|
||||
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`, :issue:`615`, :issue:`1150` and :issue:`1217`,
|
||||
users reported problems with
|
||||
logging in. We recommend to always keep the session file which Instaloader
|
||||
creates when using :option:`--login`. If a session file is present,
|
||||
:option:`--login` does not make use of the failure-prone login procedure.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Download pictures (or videos) along with their captions and other metadata from Instagram."""
|
||||
|
||||
|
||||
__version__ = '4.7.2'
|
||||
__version__ = '4.8.4'
|
||||
|
||||
|
||||
try:
|
||||
@@ -15,6 +15,8 @@ else:
|
||||
from .exceptions import *
|
||||
from .instaloader import Instaloader
|
||||
from .instaloadercontext import InstaloaderContext, RateController
|
||||
from .lateststamps import LatestStamps
|
||||
from .nodeiterator import NodeIterator, FrozenNodeIterator, resumable_iteration
|
||||
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,
|
||||
load_structure, get_json_structure)
|
||||
|
||||
+33
-10
@@ -11,8 +11,9 @@ from typing import List, Optional
|
||||
from . import (AbortDownloadException, BadCredentialsException, Instaloader, InstaloaderException,
|
||||
InvalidArgumentException, Post, Profile, ProfileNotExistsException, StoryItem,
|
||||
TwoFactorAuthRequiredException, __version__, load_structure_from_file)
|
||||
from .instaloader import get_default_session_filename
|
||||
from .instaloader import (get_default_session_filename, get_default_stamps_filename)
|
||||
from .instaloadercontext import default_user_agent
|
||||
from .lateststamps import LatestStamps
|
||||
|
||||
|
||||
def usage_string():
|
||||
@@ -76,6 +77,7 @@ def _main(instaloader: Instaloader, targetlist: List[str],
|
||||
download_tagged: bool = False,
|
||||
download_igtv: bool = False,
|
||||
fast_update: bool = False,
|
||||
latest_stamps_file: Optional[str] = None,
|
||||
max_count: Optional[int] = None, post_filter_str: Optional[str] = None,
|
||||
storyitem_filter_str: Optional[str] = None) -> None:
|
||||
"""Download set of profiles, hashtags etc. and handle logging in and session files if desired."""
|
||||
@@ -88,6 +90,10 @@ def _main(instaloader: Instaloader, targetlist: List[str],
|
||||
if storyitem_filter_str is not None:
|
||||
storyitem_filter = filterstr_to_filterfunc(storyitem_filter_str, StoryItem)
|
||||
instaloader.context.log('Only download storyitems with property "{}".'.format(storyitem_filter_str))
|
||||
latest_stamps = None
|
||||
if latest_stamps_file is not None:
|
||||
latest_stamps = LatestStamps(latest_stamps_file)
|
||||
instaloader.context.log(f"Using latest stamps from {latest_stamps_file}.")
|
||||
# Login, if desired
|
||||
if username is not None:
|
||||
if not re.match(r"^[A-Za-z0-9._]+$", username):
|
||||
@@ -108,7 +114,8 @@ def _main(instaloader: Instaloader, targetlist: List[str],
|
||||
code = input("Enter 2FA verification code: ")
|
||||
instaloader.two_factor_login(code)
|
||||
break
|
||||
except BadCredentialsException:
|
||||
except BadCredentialsException as err:
|
||||
print(err, file=sys.stderr)
|
||||
pass
|
||||
else:
|
||||
instaloader.interactive_login(username)
|
||||
@@ -172,7 +179,7 @@ def _main(instaloader: Instaloader, targetlist: List[str],
|
||||
post_filter=post_filter)
|
||||
elif re.match(r"^[A-Za-z0-9._]+$", target):
|
||||
try:
|
||||
profile = instaloader.check_profile_id(target)
|
||||
profile = instaloader.check_profile_id(target, latest_stamps)
|
||||
if instaloader.context.is_logged_in and profile.has_blocked_viewer:
|
||||
if download_profile_pic or ((download_posts or download_tagged or download_igtv)
|
||||
and not profile.is_private):
|
||||
@@ -191,7 +198,8 @@ def _main(instaloader: Instaloader, targetlist: List[str],
|
||||
instaloader.context.log("Trying again anonymously, helps in case you are just blocked.")
|
||||
with instaloader.anonymous_copy() as anonymous_loader:
|
||||
with instaloader.context.error_catcher():
|
||||
anonymous_retry_profiles.add(anonymous_loader.check_profile_id(target))
|
||||
anonymous_retry_profiles.add(anonymous_loader.check_profile_id(target,
|
||||
latest_stamps))
|
||||
instaloader.context.error("Warning: {} will be downloaded anonymously (\"{}\")."
|
||||
.format(target, err))
|
||||
else:
|
||||
@@ -206,19 +214,21 @@ def _main(instaloader: Instaloader, targetlist: List[str],
|
||||
if len(profiles) > 1:
|
||||
instaloader.context.log("Downloading {} profiles: {}".format(len(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 instaloader.context.iphone_support and profiles and (download_profile_pic or download_posts) and \
|
||||
not instaloader.context.is_logged_in:
|
||||
instaloader.context.log("Hint: Use --login to download higher-quality versions of pictures.")
|
||||
instaloader.download_profiles(profiles,
|
||||
download_profile_pic, download_posts, download_tagged, download_igtv,
|
||||
download_highlights, download_stories,
|
||||
fast_update, post_filter, storyitem_filter)
|
||||
fast_update, post_filter, storyitem_filter, latest_stamps=latest_stamps)
|
||||
if anonymous_retry_profiles:
|
||||
instaloader.context.log("Downloading anonymously: {}"
|
||||
.format(' '.join([p.username for p in anonymous_retry_profiles])))
|
||||
with instaloader.anonymous_copy() as anonymous_loader:
|
||||
anonymous_loader.download_profiles(anonymous_retry_profiles,
|
||||
download_profile_pic, download_posts, download_tagged, download_igtv,
|
||||
fast_update=fast_update, post_filter=post_filter)
|
||||
fast_update=fast_update, post_filter=post_filter,
|
||||
latest_stamps=latest_stamps)
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted by user.", file=sys.stderr)
|
||||
except AbortDownloadException as exc:
|
||||
@@ -323,7 +333,10 @@ def main():
|
||||
g_cond.add_argument('-F', '--fast-update', action='store_true',
|
||||
help='For each target, stop when encountering the first already-downloaded picture. This '
|
||||
'flag is recommended when you use Instaloader to update your personal Instagram archive.')
|
||||
|
||||
g_cond.add_argument('--latest-stamps', nargs='?', metavar='STAMPSFILE', const=get_default_stamps_filename(),
|
||||
help='Store the timestamps of latest media scraped for each profile. This allows updating '
|
||||
'your personal Instagram archive even if you delete the destination directories. '
|
||||
'If STAMPSFILE is not provided, defaults to ' + get_default_stamps_filename())
|
||||
g_cond.add_argument('--post-filter', '--only-if', metavar='filter',
|
||||
help='Expression that, if given, must evaluate to True for each post to be downloaded. Must be '
|
||||
'a syntactically valid python expression. Variables are evaluated to '
|
||||
@@ -358,10 +371,15 @@ def main():
|
||||
'{target} is replaced by the target you specified, i.e. either :feed, #hashtag or the '
|
||||
'profile name. Defaults to \'{target}\'.')
|
||||
g_how.add_argument('--filename-pattern',
|
||||
help='Prefix of filenames, relative to the directory given with '
|
||||
help='Prefix of filenames for posts and stories, relative to the directory given with '
|
||||
'--dirname-pattern. {profile} is replaced by the profile name,'
|
||||
'{target} is replaced by the target you specified, i.e. either :feed'
|
||||
'#hashtag or the profile name. Defaults to \'{date_utc}_UTC\'')
|
||||
g_how.add_argument('--title-pattern',
|
||||
help='Prefix of filenames for profile pics, hashtag profile pics, and highlight covers. '
|
||||
'Defaults to \'{date_utc}_UTC_{typename}\' if --dirname-pattern contains \'{target}\' '
|
||||
'or \'{dirname}\', or if --dirname-pattern is not specified. Otherwise defaults to '
|
||||
'\'{target}_{date_utc}_UTC_{typename}\'.')
|
||||
g_how.add_argument('--resume-prefix', metavar='PREFIX',
|
||||
help='Prefix for filenames that are used to save the information to resume an interrupted '
|
||||
'download.')
|
||||
@@ -382,6 +400,8 @@ def main():
|
||||
g_how.add_argument('--abort-on', type=http_status_code_list, metavar="STATUS_CODES",
|
||||
help='Comma-separated list of HTTP status codes that cause Instaloader to abort, bypassing all '
|
||||
'retry logic.')
|
||||
g_how.add_argument('--no-iphone', action='store_true',
|
||||
help='Do not attempt to download iPhone version of images and videos.')
|
||||
|
||||
g_misc = parser.add_argument_group('Miscellaneous Options')
|
||||
g_misc.add_argument('-q', '--quiet', action='store_true',
|
||||
@@ -441,7 +461,9 @@ def main():
|
||||
resume_prefix=resume_prefix,
|
||||
check_resume_bbd=not args.use_aged_resume_files,
|
||||
slide=args.slide,
|
||||
fatal_status_codes=args.abort_on)
|
||||
fatal_status_codes=args.abort_on,
|
||||
iphone_support=not args.no_iphone,
|
||||
title_pattern=args.title_pattern)
|
||||
_main(loader,
|
||||
args.profile,
|
||||
username=args.login.lower() if args.login is not None else None,
|
||||
@@ -454,6 +476,7 @@ def main():
|
||||
download_tagged=args.tagged,
|
||||
download_igtv=args.igtv,
|
||||
fast_update=args.fast_update,
|
||||
latest_stamps_file=args.latest_stamps,
|
||||
max_count=int(args.count) if args.count is not None else None,
|
||||
post_filter_str=args.post_filter,
|
||||
storyitem_filter_str=args.storyitem_filter)
|
||||
|
||||
@@ -65,6 +65,8 @@ class QueryReturnedNotFoundException(ConnectionException):
|
||||
class TooManyRequestsException(ConnectionException):
|
||||
pass
|
||||
|
||||
class IPhoneSupportDisabledException(InstaloaderException):
|
||||
pass
|
||||
|
||||
class AbortDownloadException(Exception):
|
||||
"""
|
||||
|
||||
+214
-66
@@ -10,7 +10,6 @@ import tempfile
|
||||
from contextlib import contextmanager, suppress
|
||||
from datetime import datetime, timezone
|
||||
from functools import wraps
|
||||
from hashlib import md5
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, IO, Iterator, List, Optional, Set, Union, cast
|
||||
@@ -21,23 +20,29 @@ import urllib3 # type: ignore
|
||||
|
||||
from .exceptions import *
|
||||
from .instaloadercontext import InstaloaderContext, RateController
|
||||
from .lateststamps import LatestStamps
|
||||
from .nodeiterator import NodeIterator, resumable_iteration
|
||||
from .structures import (Hashtag, Highlight, JsonExportable, Post, PostLocation, Profile, Story, StoryItem,
|
||||
load_structure_from_file, save_structure_to_file, PostSidecarNode)
|
||||
load_structure_from_file, save_structure_to_file, PostSidecarNode, TitlePic)
|
||||
|
||||
|
||||
def _get_config_dir() -> str:
|
||||
if platform.system() == "Windows":
|
||||
# on Windows, use %LOCALAPPDATA%\Instaloader
|
||||
localappdata = os.getenv("LOCALAPPDATA")
|
||||
if localappdata is not None:
|
||||
return os.path.join(localappdata, "Instaloader")
|
||||
# legacy fallback - store in temp dir if %LOCALAPPDATA% is not set
|
||||
return os.path.join(tempfile.gettempdir(), ".instaloader-" + getpass.getuser())
|
||||
# on Unix, use ~/.config/instaloader
|
||||
return os.path.join(os.getenv("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "instaloader")
|
||||
|
||||
|
||||
def get_default_session_filename(username: str) -> str:
|
||||
"""Returns default session filename for given username."""
|
||||
configdir = _get_config_dir()
|
||||
sessionfilename = "session-{}".format(username)
|
||||
if platform.system() == "Windows":
|
||||
# on Windows, use %LOCALAPPDATA%\Instaloader\session-USERNAME
|
||||
localappdata = os.getenv("LOCALAPPDATA")
|
||||
if localappdata is not None:
|
||||
return os.path.join(localappdata, "Instaloader", sessionfilename)
|
||||
# legacy fallback - store in temp dir if %LOCALAPPDATA% is not set
|
||||
return os.path.join(tempfile.gettempdir(), ".instaloader-" + getpass.getuser(), sessionfilename)
|
||||
# on Unix, use ~/.config/instaloader/session-USERNAME
|
||||
return os.path.join(os.getenv("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "instaloader", sessionfilename)
|
||||
return os.path.join(configdir, sessionfilename)
|
||||
|
||||
|
||||
def get_legacy_session_filename(username: str) -> str:
|
||||
@@ -47,6 +52,17 @@ def get_legacy_session_filename(username: str) -> str:
|
||||
return filename.lower()
|
||||
|
||||
|
||||
def get_default_stamps_filename() -> str:
|
||||
"""
|
||||
Returns default filename for latest stamps database.
|
||||
|
||||
.. versionadded:: 4.8
|
||||
|
||||
"""
|
||||
configdir = _get_config_dir()
|
||||
return os.path.join(configdir, "latest-stamps.ini")
|
||||
|
||||
|
||||
def format_string_contains_key(format_string: str, key: str) -> bool:
|
||||
# pylint:disable=unused-variable
|
||||
for literal_text, field_name, format_spec, conversion in string.Formatter().parse(format_string):
|
||||
@@ -102,7 +118,7 @@ class _ArbitraryItemFormatter(string.Formatter):
|
||||
|
||||
def get_value(self, key, args, kwargs):
|
||||
"""Override to substitute {ATTRIBUTE} by attributes of our _item."""
|
||||
if key == 'filename' and isinstance(self._item, (Post, StoryItem, PostSidecarNode)):
|
||||
if key == 'filename' and isinstance(self._item, (Post, StoryItem, PostSidecarNode, TitlePic)):
|
||||
return "{filename}"
|
||||
if hasattr(self._item, key):
|
||||
return getattr(self._item, key)
|
||||
@@ -144,6 +160,9 @@ class Instaloader:
|
||||
:param user_agent: :option:`--user-agent`
|
||||
:param dirname_pattern: :option:`--dirname-pattern`, default is ``{target}``
|
||||
:param filename_pattern: :option:`--filename-pattern`, default is ``{date_utc}_UTC``
|
||||
:param title_pattern:
|
||||
:option:`--title-pattern`, default is ``{date_utc}_UTC_{typename}`` if ``dirname_pattern`` contains
|
||||
``{target}`` or ``{profile}``, ``{target}_{date_utc}_UTC_{typename}`` otherwise.
|
||||
:param download_pictures: not :option:`--no-pictures`
|
||||
:param download_videos: not :option:`--no-videos`
|
||||
:param download_video_thumbnails: not :option:`--no-video-thumbnails`
|
||||
@@ -162,6 +181,7 @@ class Instaloader:
|
||||
: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`
|
||||
:param iphone_support: not :option:`--no-iphone`
|
||||
|
||||
.. attribute:: context
|
||||
|
||||
@@ -189,14 +209,25 @@ class Instaloader:
|
||||
resume_prefix: Optional[str] = "iterator",
|
||||
check_resume_bbd: bool = True,
|
||||
slide: Optional[str] = None,
|
||||
fatal_status_codes: Optional[List[int]] = None):
|
||||
fatal_status_codes: Optional[List[int]] = None,
|
||||
iphone_support: bool = True,
|
||||
title_pattern: Optional[str] = None):
|
||||
|
||||
self.context = InstaloaderContext(sleep, quiet, user_agent, max_connection_attempts,
|
||||
request_timeout, rate_controller, fatal_status_codes)
|
||||
request_timeout, rate_controller, fatal_status_codes,
|
||||
iphone_support)
|
||||
|
||||
# configuration parameters
|
||||
self.dirname_pattern = dirname_pattern or "{target}"
|
||||
self.filename_pattern = filename_pattern or "{date_utc}_UTC"
|
||||
if title_pattern is not None:
|
||||
self.title_pattern = title_pattern
|
||||
else:
|
||||
if (format_string_contains_key(self.dirname_pattern, 'profile') or
|
||||
format_string_contains_key(self.dirname_pattern, 'target')):
|
||||
self.title_pattern = '{date_utc}_UTC_{typename}'
|
||||
else:
|
||||
self.title_pattern = '{target}_{date_utc}_UTC_{typename}'
|
||||
self.download_pictures = download_pictures
|
||||
self.download_videos = download_videos
|
||||
self.download_video_thumbnails = download_video_thumbnails
|
||||
@@ -259,7 +290,8 @@ class Instaloader:
|
||||
resume_prefix=self.resume_prefix,
|
||||
check_resume_bbd=self.check_resume_bbd,
|
||||
slide=self.slide,
|
||||
fatal_status_codes=self.context.fatal_status_codes)
|
||||
fatal_status_codes=self.context.fatal_status_codes,
|
||||
iphone_support=self.context.iphone_support)
|
||||
yield new_loader
|
||||
self.context.error_log.extend(new_loader.context.error_log)
|
||||
new_loader.context.error_log = [] # avoid double-printing of errors
|
||||
@@ -421,9 +453,12 @@ class Instaloader:
|
||||
def save_location(self, filename: str, location: PostLocation, mtime: datetime) -> None:
|
||||
"""Save post location name and Google Maps link."""
|
||||
filename += '_location.txt'
|
||||
location_string = (location.name + "\n" +
|
||||
"https://maps.google.com/maps?q={0},{1}&ll={0},{1}\n".format(location.lat,
|
||||
location.lng))
|
||||
if location.lat is not None and location.lng is not None:
|
||||
location_string = (location.name + "\n" +
|
||||
"https://maps.google.com/maps?q={0},{1}&ll={0},{1}\n".format(location.lat,
|
||||
location.lng))
|
||||
else:
|
||||
location_string = location.name
|
||||
with open(filename, 'wb') as text_file:
|
||||
with BytesIO(location_string.encode()) as bio:
|
||||
shutil.copyfileobj(cast(IO, bio), text_file)
|
||||
@@ -456,31 +491,51 @@ class Instaloader:
|
||||
|
||||
.. versionadded:: 4.3"""
|
||||
|
||||
def _epoch_to_string(epoch: datetime) -> str:
|
||||
return epoch.strftime('%Y-%m-%d_%H-%M-%S_UTC')
|
||||
|
||||
http_response = self.context.get_raw(url)
|
||||
date_object = None # type: Optional[datetime]
|
||||
if 'Last-Modified' in http_response.headers:
|
||||
date_object = datetime.strptime(http_response.headers["Last-Modified"], '%a, %d %b %Y %H:%M:%S GMT')
|
||||
date_object = date_object.replace(tzinfo=timezone.utc)
|
||||
pic_bytes = None
|
||||
pic_identifier = _epoch_to_string(date_object)
|
||||
else:
|
||||
pic_bytes = http_response.content
|
||||
pic_identifier = md5(pic_bytes).hexdigest()[:16]
|
||||
filename = self.format_filename_within_target_path(target, owner_profile, pic_identifier, name_suffix, 'jpg')
|
||||
ig_filename = url.split('/')[-1].split('?')[0]
|
||||
pic_data = TitlePic(owner_profile, target, name_suffix, ig_filename, date_object)
|
||||
dirname = _PostPathFormatter(pic_data).format(self.dirname_pattern, target=target)
|
||||
filename_template = os.path.join(dirname,
|
||||
_PostPathFormatter(pic_data).format(self.title_pattern, target=target))
|
||||
filename = self.__prepare_filename(filename_template, lambda: url) + ".jpg"
|
||||
content_length = http_response.headers.get('Content-Length', None)
|
||||
if os.path.isfile(filename) and (not self.context.is_logged_in or
|
||||
(content_length is not None and
|
||||
os.path.getsize(filename) >= int(content_length))):
|
||||
self.context.log(filename + ' already exists')
|
||||
return None
|
||||
return
|
||||
os.makedirs(os.path.dirname(filename), exist_ok=True)
|
||||
self.context.write_raw(pic_bytes if pic_bytes else http_response, filename)
|
||||
if date_object:
|
||||
os.utime(filename, (datetime.now().timestamp(), date_object.timestamp()))
|
||||
self.context.log('') # log output of _get_and_write_raw() does not produce \n
|
||||
|
||||
def download_profilepic_if_new(self, profile: Profile, latest_stamps: Optional[LatestStamps]) -> None:
|
||||
"""
|
||||
Downloads and saves profile pic if it has not been downloaded before.
|
||||
|
||||
:param latest_stamps: Database with the last downloaded data. If not present,
|
||||
the profile pic is downloaded unless it already exists
|
||||
|
||||
.. versionadded:: 4.8
|
||||
"""
|
||||
if latest_stamps is None:
|
||||
self.download_profilepic(profile)
|
||||
return
|
||||
profile_pic_basename = profile.profile_pic_url.split('/')[-1].split('?')[0]
|
||||
saved_basename = latest_stamps.get_profile_pic(profile.username)
|
||||
if saved_basename == profile_pic_basename:
|
||||
return
|
||||
self.download_profilepic(profile)
|
||||
latest_stamps.set_profile_pic(profile.username, profile_pic_basename)
|
||||
|
||||
def download_profilepic(self, profile: Profile) -> None:
|
||||
"""Downloads and saves profile pic."""
|
||||
self.download_title_pic(profile.profile_pic_url, profile.username.lower(), 'profile_pic', profile)
|
||||
@@ -568,7 +623,8 @@ class Instaloader:
|
||||
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):
|
||||
def format_filename(self, item: Union[Post, StoryItem, PostSidecarNode, TitlePic],
|
||||
target: Optional[Union[str, Path]] = None):
|
||||
"""Format filename of a :class:`Post` or :class:`StoryItem` according to ``filename-pattern`` parameter.
|
||||
|
||||
.. versionadded:: 4.1"""
|
||||
@@ -714,7 +770,8 @@ class Instaloader:
|
||||
userids: Optional[List[Union[int, Profile]]] = None,
|
||||
fast_update: bool = False,
|
||||
filename_target: Optional[str] = ':stories',
|
||||
storyitem_filter: Optional[Callable[[StoryItem], bool]] = None) -> None:
|
||||
storyitem_filter: Optional[Callable[[StoryItem], bool]] = None,
|
||||
latest_stamps: Optional[LatestStamps] = None) -> None:
|
||||
"""
|
||||
Download available stories from user followees or all stories of users whose ID are given.
|
||||
Does not mark stories as seen.
|
||||
@@ -725,20 +782,37 @@ class Instaloader:
|
||||
:param filename_target: Replacement for {target} in dirname_pattern and filename_pattern
|
||||
or None if profile name should be used instead
|
||||
:param storyitem_filter: function(storyitem), which returns True if given StoryItem should be downloaded
|
||||
:param latest_stamps: Database with the last times each user was scraped
|
||||
:raises LoginRequiredException: If called without being logged in.
|
||||
|
||||
.. versionchanged:: 4.8
|
||||
Add `latest_stamps` parameter.
|
||||
"""
|
||||
|
||||
if not userids:
|
||||
self.context.log("Retrieving all visible stories...")
|
||||
profile_count = None
|
||||
else:
|
||||
userids = [p if isinstance(p, int) else p.userid for p in userids]
|
||||
profile_count = len(userids)
|
||||
|
||||
for user_story in self.get_stories(userids):
|
||||
for i, user_story in enumerate(self.get_stories(userids), start=1):
|
||||
name = user_story.owner_username
|
||||
self.context.log("Retrieving stories from profile {}.".format(name))
|
||||
if profile_count is not None:
|
||||
msg = "[{0:{w}d}/{1:{w}d}] Retrieving stories from profile {2}.".format(i, profile_count, name,
|
||||
w=len(str(profile_count)))
|
||||
else:
|
||||
msg = "[{:3d}] Retrieving stories from profile {}.".format(i, name)
|
||||
self.context.log(msg)
|
||||
totalcount = user_story.itemcount
|
||||
count = 1
|
||||
if latest_stamps is not None:
|
||||
# pylint:disable=cell-var-from-loop
|
||||
last_scraped = latest_stamps.get_last_story_timestamp(name)
|
||||
scraped_timestamp = datetime.now().astimezone()
|
||||
for item in user_story.get_items():
|
||||
if latest_stamps is not None and item.date_utc.replace(tzinfo=timezone.utc) <= last_scraped:
|
||||
break
|
||||
if storyitem_filter is not None and not storyitem_filter(item):
|
||||
self.context.log("<{} skipped>".format(item), flush=True)
|
||||
continue
|
||||
@@ -748,6 +822,8 @@ class Instaloader:
|
||||
downloaded = self.download_storyitem(item, filename_target if filename_target else name)
|
||||
if fast_update and not downloaded:
|
||||
break
|
||||
if latest_stamps is not None:
|
||||
latest_stamps.set_last_story_timestamp(name, scraped_timestamp)
|
||||
|
||||
def download_storyitem(self, item: StoryItem, target: Union[str, Path]) -> bool:
|
||||
"""Download one user story.
|
||||
@@ -858,7 +934,8 @@ class Instaloader:
|
||||
post_filter: Optional[Callable[[Post], bool]] = None,
|
||||
max_count: Optional[int] = None,
|
||||
total_count: Optional[int] = None,
|
||||
owner_profile: Optional[Profile] = None) -> None:
|
||||
owner_profile: Optional[Profile] = None,
|
||||
takewhile: Optional[Callable[[Post], bool]] = None) -> None:
|
||||
"""
|
||||
Download the Posts returned by given Post Iterator.
|
||||
|
||||
@@ -867,6 +944,9 @@ class Instaloader:
|
||||
.. versionchanged:: 4.5
|
||||
Transparently resume an aborted operation if `posts` is a :class:`NodeIterator`.
|
||||
|
||||
.. versionchanged:: 4.8
|
||||
Add `takewhile` parameter.
|
||||
|
||||
:param posts: Post Iterator to loop through.
|
||||
:param target: Target name.
|
||||
:param fast_update: :option:`--fast-update`.
|
||||
@@ -874,12 +954,15 @@ class Instaloader:
|
||||
:param max_count: Maximum count of Posts to download (:option:`--count`).
|
||||
:param total_count: Total number of posts returned by given iterator.
|
||||
:param owner_profile: Associated profile, if any.
|
||||
:param takewhile: Expression evaluated for each post. Once it returns false, downloading stops.
|
||||
"""
|
||||
displayed_count = (max_count if total_count is None or max_count is not None and max_count < total_count
|
||||
else total_count)
|
||||
sanitized_target = target
|
||||
if isinstance(target, str):
|
||||
sanitized_target = _PostPathFormatter.sanitize_path(target)
|
||||
if takewhile is None:
|
||||
takewhile = lambda _: True
|
||||
with resumable_iteration(
|
||||
context=self.context,
|
||||
iterator=posts,
|
||||
@@ -892,7 +975,7 @@ class Instaloader:
|
||||
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:
|
||||
if (max_count is not None and number > max_count) or not takewhile(post):
|
||||
break
|
||||
if displayed_count is not None:
|
||||
self.context.log("[{0:{w}d}/{1:{w}d}] ".format(number, displayed_count,
|
||||
@@ -1111,25 +1194,47 @@ class Instaloader:
|
||||
|
||||
def download_tagged(self, profile: Profile, fast_update: bool = False,
|
||||
target: Optional[str] = None,
|
||||
post_filter: Optional[Callable[[Post], bool]] = None) -> None:
|
||||
post_filter: Optional[Callable[[Post], bool]] = None,
|
||||
latest_stamps: Optional[LatestStamps] = None) -> None:
|
||||
"""Download all posts where a profile is tagged.
|
||||
|
||||
.. versionadded:: 4.1"""
|
||||
.. versionadded:: 4.1
|
||||
|
||||
.. versionchanged:: 4.8
|
||||
Add `latest_stamps` parameter."""
|
||||
self.context.log("Retrieving tagged posts for profile {}.".format(profile.username))
|
||||
self.posts_download_loop(profile.get_tagged_posts(),
|
||||
posts_takewhile: Optional[Callable[[Post], bool]] = None
|
||||
if latest_stamps is not None:
|
||||
last_scraped = latest_stamps.get_last_tagged_timestamp(profile.username)
|
||||
posts_takewhile = lambda p: p.date_utc.replace(tzinfo=timezone.utc) > last_scraped
|
||||
tagged_posts = profile.get_tagged_posts()
|
||||
self.posts_download_loop(tagged_posts,
|
||||
target if target
|
||||
else (Path(_PostPathFormatter.sanitize_path(profile.username)) /
|
||||
_PostPathFormatter.sanitize_path(':tagged')),
|
||||
fast_update, post_filter)
|
||||
fast_update, post_filter, takewhile=posts_takewhile)
|
||||
if latest_stamps is not None and tagged_posts.first_item is not None:
|
||||
latest_stamps.set_last_tagged_timestamp(profile.username, tagged_posts.first_item.date_local.astimezone())
|
||||
|
||||
def download_igtv(self, profile: Profile, fast_update: bool = False,
|
||||
post_filter: Optional[Callable[[Post], bool]] = None) -> None:
|
||||
post_filter: Optional[Callable[[Post], bool]] = None,
|
||||
latest_stamps: Optional[LatestStamps] = None) -> None:
|
||||
"""Download IGTV videos of a profile.
|
||||
|
||||
.. versionadded:: 4.3"""
|
||||
.. versionadded:: 4.3
|
||||
|
||||
.. versionchanged:: 4.8
|
||||
Add `latest_stamps` parameter."""
|
||||
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,
|
||||
total_count=profile.igtvcount, owner_profile=profile)
|
||||
posts_takewhile: Optional[Callable[[Post], bool]] = None
|
||||
if latest_stamps is not None:
|
||||
last_scraped = latest_stamps.get_last_igtv_timestamp(profile.username)
|
||||
posts_takewhile = lambda p: p.date_utc.replace(tzinfo=timezone.utc) > last_scraped
|
||||
igtv_posts = profile.get_igtv_posts()
|
||||
self.posts_download_loop(igtv_posts, profile.username, fast_update, post_filter,
|
||||
total_count=profile.igtvcount, owner_profile=profile, takewhile=posts_takewhile)
|
||||
if latest_stamps is not None and igtv_posts.first_item is not None:
|
||||
latest_stamps.set_last_igtv_timestamp(profile.username, igtv_posts.first_item.date_local.astimezone())
|
||||
|
||||
def _get_id_filename(self, profile_name: str) -> str:
|
||||
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
||||
@@ -1141,9 +1246,22 @@ class Instaloader:
|
||||
return os.path.join(self.dirname_pattern.format(),
|
||||
'{0}_id'.format(profile_name.lower()))
|
||||
|
||||
def load_profile_id(self, profile_name: str) -> Optional[int]:
|
||||
"""
|
||||
Load ID of profile from profile directory.
|
||||
|
||||
.. versionadded:: 4.8
|
||||
"""
|
||||
id_filename = self._get_id_filename(profile_name)
|
||||
try:
|
||||
with open(id_filename, 'rb') as id_file:
|
||||
return int(id_file.read())
|
||||
except (FileNotFoundError, ValueError):
|
||||
return None
|
||||
|
||||
def save_profile_id(self, profile: Profile):
|
||||
"""
|
||||
Store ID of profile locally.
|
||||
Store ID of profile on profile directory.
|
||||
|
||||
.. versionadded:: 4.0.6
|
||||
"""
|
||||
@@ -1153,13 +1271,18 @@ class Instaloader:
|
||||
text_file.write(str(profile.userid) + "\n")
|
||||
self.context.log("Stored ID {0} for profile {1}.".format(profile.userid, profile.username))
|
||||
|
||||
def check_profile_id(self, profile_name: str) -> Profile:
|
||||
def check_profile_id(self, profile_name: str, latest_stamps: Optional[LatestStamps] = None) -> Profile:
|
||||
"""
|
||||
Consult locally stored ID of profile with given name, check whether ID matches and whether name
|
||||
has changed and return current name of the profile, and store ID of profile.
|
||||
|
||||
:param profile_name: Profile name
|
||||
:param latest_stamps: Database of downloaded data. If present, IDs are retrieved from it,
|
||||
otherwise from the target directory
|
||||
:return: Instance of current profile
|
||||
|
||||
.. versionchanged:: 4.8
|
||||
Add `latest_stamps` parameter.
|
||||
"""
|
||||
profile = None
|
||||
profile_name_not_exists_err = None
|
||||
@@ -1167,10 +1290,11 @@ class Instaloader:
|
||||
profile = Profile.from_username(self.context, profile_name)
|
||||
except ProfileNotExistsException as err:
|
||||
profile_name_not_exists_err = err
|
||||
id_filename = self._get_id_filename(profile_name)
|
||||
try:
|
||||
with open(id_filename, 'rb') as id_file:
|
||||
profile_id = int(id_file.read())
|
||||
if latest_stamps is None:
|
||||
profile_id = self.load_profile_id(profile_name)
|
||||
else:
|
||||
profile_id = latest_stamps.get_profile_id(profile_name)
|
||||
if profile_id is not None:
|
||||
if (profile is None) or \
|
||||
(profile_id != profile.userid):
|
||||
if profile is not None:
|
||||
@@ -1181,23 +1305,27 @@ class Instaloader:
|
||||
profile_id))
|
||||
profile_from_id = Profile.from_id(self.context, profile_id)
|
||||
newname = profile_from_id.username
|
||||
self.context.log("Profile {0} has changed its name to {1}.".format(profile_name, newname))
|
||||
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
||||
format_string_contains_key(self.dirname_pattern, 'target'))):
|
||||
os.rename(self.dirname_pattern.format(profile=profile_name.lower(),
|
||||
target=profile_name.lower()),
|
||||
self.dirname_pattern.format(profile=newname.lower(),
|
||||
target=newname.lower()))
|
||||
self.context.error("Profile {0} has changed its name to {1}.".format(profile_name, newname))
|
||||
if latest_stamps is None:
|
||||
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
||||
format_string_contains_key(self.dirname_pattern, 'target'))):
|
||||
os.rename(self.dirname_pattern.format(profile=profile_name.lower(),
|
||||
target=profile_name.lower()),
|
||||
self.dirname_pattern.format(profile=newname.lower(),
|
||||
target=newname.lower()))
|
||||
else:
|
||||
os.rename('{0}/{1}_id'.format(self.dirname_pattern.format(), profile_name.lower()),
|
||||
'{0}/{1}_id'.format(self.dirname_pattern.format(), newname.lower()))
|
||||
else:
|
||||
os.rename('{0}/{1}_id'.format(self.dirname_pattern.format(), profile_name.lower()),
|
||||
'{0}/{1}_id'.format(self.dirname_pattern.format(), newname.lower()))
|
||||
latest_stamps.rename_profile(profile_name, newname)
|
||||
return profile_from_id
|
||||
# profile exists and profile id matches saved id
|
||||
return profile
|
||||
except (FileNotFoundError, ValueError):
|
||||
pass
|
||||
if profile is not None:
|
||||
self.save_profile_id(profile)
|
||||
if latest_stamps is None:
|
||||
self.save_profile_id(profile)
|
||||
else:
|
||||
latest_stamps.save_profile_id(profile.username, profile.userid)
|
||||
return profile
|
||||
if profile_name_not_exists_err:
|
||||
raise profile_name_not_exists_err
|
||||
@@ -1212,7 +1340,8 @@ class Instaloader:
|
||||
fast_update: bool = False,
|
||||
post_filter: Optional[Callable[[Post], bool]] = None,
|
||||
storyitem_filter: Optional[Callable[[Post], bool]] = None,
|
||||
raise_errors: bool = False):
|
||||
raise_errors: bool = False,
|
||||
latest_stamps: Optional[LatestStamps] = None):
|
||||
"""High-level method to download set of profiles.
|
||||
|
||||
:param profiles: Set of profiles to download.
|
||||
@@ -1228,11 +1357,15 @@ class Instaloader:
|
||||
:param raise_errors:
|
||||
Whether :exc:`LoginRequiredException` and :exc:`PrivateProfileNotFollowedException` should be raised or
|
||||
catched and printed with :meth:`InstaloaderContext.error_catcher`.
|
||||
:param latest_stamps: :option:`--latest-stamps`.
|
||||
|
||||
.. versionadded:: 4.1
|
||||
|
||||
.. versionchanged:: 4.3
|
||||
Add `igtv` parameter.
|
||||
|
||||
.. versionchanged:: 4.8
|
||||
Add `latest_stamps` parameter.
|
||||
"""
|
||||
|
||||
@contextmanager
|
||||
@@ -1242,14 +1375,16 @@ class Instaloader:
|
||||
# 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
|
||||
|
||||
for profile in profiles:
|
||||
for i, profile in enumerate(profiles, start=1):
|
||||
self.context.log("[{0:{w}d}/{1:{w}d}] Downloading profile {2}".format(i, len(profiles), profile.username,
|
||||
w=len(str(len(profiles)))))
|
||||
with error_handler(profile.username): # type: ignore # (ignore type for Python 3.5 support)
|
||||
profile_name = profile.username
|
||||
|
||||
# Download profile picture
|
||||
if profile_pic:
|
||||
with self.context.error_catcher('Download profile picture of {}'.format(profile_name)):
|
||||
self.download_profilepic(profile)
|
||||
self.download_profilepic_if_new(profile, latest_stamps)
|
||||
|
||||
# Save metadata as JSON if desired.
|
||||
if self.save_metadata:
|
||||
@@ -1271,12 +1406,14 @@ class Instaloader:
|
||||
# Download tagged, if requested
|
||||
if tagged:
|
||||
with self.context.error_catcher('Download tagged of {}'.format(profile_name)):
|
||||
self.download_tagged(profile, fast_update=fast_update, post_filter=post_filter)
|
||||
self.download_tagged(profile, fast_update=fast_update, post_filter=post_filter,
|
||||
latest_stamps=latest_stamps)
|
||||
|
||||
# Download IGTV, if requested
|
||||
if igtv:
|
||||
with self.context.error_catcher('Download IGTV of {}'.format(profile_name)):
|
||||
self.download_igtv(profile, fast_update=fast_update, post_filter=post_filter)
|
||||
self.download_igtv(profile, fast_update=fast_update, post_filter=post_filter,
|
||||
latest_stamps=latest_stamps)
|
||||
|
||||
# Download highlights, if requested
|
||||
if highlights:
|
||||
@@ -1286,14 +1423,24 @@ class Instaloader:
|
||||
# Iterate over pictures and download them
|
||||
if posts:
|
||||
self.context.log("Retrieving posts from profile {}.".format(profile_name))
|
||||
self.posts_download_loop(profile.get_posts(), profile_name, fast_update, post_filter,
|
||||
total_count=profile.mediacount, owner_profile=profile)
|
||||
posts_takewhile: Optional[Callable[[Post], bool]] = None
|
||||
if latest_stamps is not None:
|
||||
# pylint:disable=cell-var-from-loop
|
||||
last_scraped = latest_stamps.get_last_post_timestamp(profile_name)
|
||||
posts_takewhile = lambda p: p.date_utc.replace(tzinfo=timezone.utc) > last_scraped
|
||||
posts_to_download = profile.get_posts()
|
||||
self.posts_download_loop(posts_to_download, profile_name, fast_update, post_filter,
|
||||
total_count=profile.mediacount, owner_profile=profile,
|
||||
takewhile=posts_takewhile)
|
||||
if latest_stamps is not None and posts_to_download.first_item is not None:
|
||||
latest_stamps.set_last_post_timestamp(profile_name,
|
||||
posts_to_download.first_item.date_local.astimezone())
|
||||
|
||||
if stories and profiles:
|
||||
with self.context.error_catcher("Download stories"):
|
||||
self.context.log("Downloading stories")
|
||||
self.download_stories(userids=list(profiles), fast_update=fast_update, filename_target=None,
|
||||
storyitem_filter=storyitem_filter)
|
||||
storyitem_filter=storyitem_filter, latest_stamps=latest_stamps)
|
||||
|
||||
def download_profile(self, profile_name: Union[str, Profile],
|
||||
profile_pic: bool = True, profile_pic_only: bool = False,
|
||||
@@ -1392,5 +1539,6 @@ class Instaloader:
|
||||
code = input("Enter 2FA verification code: ")
|
||||
self.two_factor_login(code)
|
||||
break
|
||||
except BadCredentialsException:
|
||||
except BadCredentialsException as err:
|
||||
print(err, file=sys.stderr)
|
||||
pass
|
||||
|
||||
@@ -54,7 +54,8 @@ class InstaloaderContext:
|
||||
def __init__(self, sleep: bool = True, quiet: bool = False, user_agent: Optional[str] = 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):
|
||||
fatal_status_codes: Optional[List[int]] = None,
|
||||
iphone_support: bool = True):
|
||||
|
||||
self.user_agent = user_agent if user_agent is not None else default_user_agent()
|
||||
self.request_timeout = request_timeout
|
||||
@@ -66,6 +67,7 @@ class InstaloaderContext:
|
||||
self._graphql_page_length = 50
|
||||
self._root_rhx_gis = None
|
||||
self.two_factor_auth_pending = None
|
||||
self.iphone_support = iphone_support
|
||||
|
||||
# error log, filled with error() and printed at the end of Instaloader.main()
|
||||
self.error_log = [] # type: List[str]
|
||||
@@ -152,7 +154,6 @@ class InstaloaderContext:
|
||||
if empty_session_only:
|
||||
del header['Host']
|
||||
del header['Origin']
|
||||
del header['Referer']
|
||||
del header['X-Instagram-AJAX']
|
||||
del header['X-Requested-With']
|
||||
return header
|
||||
@@ -210,8 +211,8 @@ class InstaloaderContext:
|
||||
# Override default timeout behavior.
|
||||
# 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.get('https://www.instagram.com/web/__mid/')
|
||||
csrf_token = session.cookies.get_dict()['csrftoken']
|
||||
csrf_json = self.get_json('accounts/login/', {}, session=session)
|
||||
csrf_token = csrf_json['config']['csrf_token']
|
||||
session.headers.update({'X-CSRFToken': csrf_token})
|
||||
# Not using self.get_json() here, because we need to access csrftoken cookie
|
||||
self.do_sleep()
|
||||
@@ -283,9 +284,9 @@ class InstaloaderContext:
|
||||
resp_json = login.json()
|
||||
if resp_json['status'] != 'ok':
|
||||
if 'message' in resp_json:
|
||||
raise BadCredentialsException("Login error: {}".format(resp_json['message']))
|
||||
raise BadCredentialsException("2FA error: {}".format(resp_json['message']))
|
||||
else:
|
||||
raise BadCredentialsException("Login error: \"{}\" status.".format(resp_json['status']))
|
||||
raise BadCredentialsException("2FA error: \"{}\" status.".format(resp_json['status']))
|
||||
session.headers.update({'X-CSRFToken': login.cookies['csrftoken']})
|
||||
self._session = session
|
||||
self.username = user
|
||||
@@ -528,6 +529,28 @@ class InstaloaderContext:
|
||||
:raises ConnectionException: When download repeatedly failed."""
|
||||
self.write_raw(self.get_raw(url), filename)
|
||||
|
||||
def head(self, url: str, allow_redirects: bool = False) -> requests.Response:
|
||||
"""HEAD a URL anonymously.
|
||||
|
||||
:raises QueryReturnedNotFoundException: When the server responds with a 404.
|
||||
:raises QueryReturnedForbiddenException: When the server responds with a 403.
|
||||
:raises ConnectionException: When request failed.
|
||||
|
||||
.. versionadded:: 4.7.6
|
||||
"""
|
||||
with self.get_anonymous_session() as anonymous_session:
|
||||
resp = anonymous_session.head(url, allow_redirects=allow_redirects)
|
||||
if resp.status_code == 200:
|
||||
return resp
|
||||
else:
|
||||
if resp.status_code == 403:
|
||||
# suspected invalid URL signature
|
||||
raise QueryReturnedForbiddenException("403 when accessing {}.".format(url))
|
||||
if resp.status_code == 404:
|
||||
# 404 not worth retrying.
|
||||
raise QueryReturnedNotFoundException("404 when accessing {}.".format(url))
|
||||
raise ConnectionException("HTTP error code {}.".format(resp.status_code))
|
||||
|
||||
@property
|
||||
def root_rhx_gis(self) -> Optional[str]:
|
||||
"""rhx_gis string returned in the / query."""
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import configparser
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class LatestStamps:
|
||||
"""LatestStamps class.
|
||||
|
||||
Convenience class for retrieving and storing data from the :option:`--latest-stamps` file.
|
||||
|
||||
:param latest_stamps_file: path to file.
|
||||
|
||||
.. versionadded:: 4.8"""
|
||||
PROFILE_ID = 'profile-id'
|
||||
PROFILE_PIC = 'profile-pic'
|
||||
POST_TIMESTAMP = 'post-timestamp'
|
||||
TAGGED_TIMESTAMP = 'tagged-timestamp'
|
||||
IGTV_TIMESTAMP = 'igtv-timestamp'
|
||||
STORY_TIMESTAMP = 'story-timestamp'
|
||||
ISO_FORMAT = '%Y-%m-%dT%H:%M:%S.%f%z'
|
||||
|
||||
def __init__(self, latest_stamps_file):
|
||||
self.file = latest_stamps_file
|
||||
self.data = configparser.ConfigParser()
|
||||
self.data.read(latest_stamps_file)
|
||||
|
||||
def _save(self):
|
||||
with open(self.file, 'w') as f:
|
||||
self.data.write(f)
|
||||
|
||||
def _ensure_section(self, section: str):
|
||||
if not self.data.has_section(section):
|
||||
self.data.add_section(section)
|
||||
|
||||
def get_profile_id(self, profile_name: str) -> Optional[int]:
|
||||
"""Returns stored ID of profile."""
|
||||
try:
|
||||
return self.data.getint(profile_name, self.PROFILE_ID)
|
||||
except (configparser.Error, ValueError):
|
||||
return None
|
||||
|
||||
def save_profile_id(self, profile_name: str, profile_id: int):
|
||||
"""Stores ID of profile."""
|
||||
self._ensure_section(profile_name)
|
||||
self.data.set(profile_name, self.PROFILE_ID, str(profile_id))
|
||||
self._save()
|
||||
|
||||
def rename_profile(self, old_profile: str, new_profile: str):
|
||||
"""Renames a profile."""
|
||||
self._ensure_section(new_profile)
|
||||
for option in [self.PROFILE_ID, self.PROFILE_PIC, self.POST_TIMESTAMP,
|
||||
self.TAGGED_TIMESTAMP, self.IGTV_TIMESTAMP, self.STORY_TIMESTAMP]:
|
||||
if self.data.has_option(old_profile, option):
|
||||
value = self.data.get(old_profile, option)
|
||||
self.data.set(new_profile, option, value)
|
||||
self.data.remove_section(old_profile)
|
||||
self._save()
|
||||
|
||||
def _get_timestamp(self, section: str, key: str) -> datetime:
|
||||
try:
|
||||
return datetime.strptime(self.data.get(section, key), self.ISO_FORMAT)
|
||||
except (configparser.Error, ValueError):
|
||||
return datetime.fromtimestamp(0, timezone.utc)
|
||||
|
||||
def _set_timestamp(self, section: str, key: str, timestamp: datetime):
|
||||
self._ensure_section(section)
|
||||
self.data.set(section, key, timestamp.strftime(self.ISO_FORMAT))
|
||||
self._save()
|
||||
|
||||
def get_last_post_timestamp(self, profile_name: str) -> datetime:
|
||||
"""Returns timestamp of last download of a profile's posts."""
|
||||
return self._get_timestamp(profile_name, self.POST_TIMESTAMP)
|
||||
|
||||
def set_last_post_timestamp(self, profile_name: str, timestamp: datetime):
|
||||
"""Sets timestamp of last download of a profile's posts."""
|
||||
self._set_timestamp(profile_name, self.POST_TIMESTAMP, timestamp)
|
||||
|
||||
def get_last_tagged_timestamp(self, profile_name: str) -> datetime:
|
||||
"""Returns timestamp of last download of a profile's tagged posts."""
|
||||
return self._get_timestamp(profile_name, self.TAGGED_TIMESTAMP)
|
||||
|
||||
def set_last_tagged_timestamp(self, profile_name: str, timestamp: datetime):
|
||||
"""Sets timestamp of last download of a profile's tagged posts."""
|
||||
self._set_timestamp(profile_name, self.TAGGED_TIMESTAMP, timestamp)
|
||||
|
||||
def get_last_igtv_timestamp(self, profile_name: str) -> datetime:
|
||||
"""Returns timestamp of last download of a profile's igtv posts."""
|
||||
return self._get_timestamp(profile_name, self.IGTV_TIMESTAMP)
|
||||
|
||||
def set_last_igtv_timestamp(self, profile_name: str, timestamp: datetime):
|
||||
"""Sets timestamp of last download of a profile's igtv posts."""
|
||||
self._set_timestamp(profile_name, self.IGTV_TIMESTAMP, timestamp)
|
||||
|
||||
def get_last_story_timestamp(self, profile_name: str) -> datetime:
|
||||
"""Returns timestamp of last download of a profile's stories."""
|
||||
return self._get_timestamp(profile_name, self.STORY_TIMESTAMP)
|
||||
|
||||
def set_last_story_timestamp(self, profile_name: str, timestamp: datetime):
|
||||
"""Sets timestamp of last download of a profile's stories."""
|
||||
self._set_timestamp(profile_name, self.STORY_TIMESTAMP, timestamp)
|
||||
|
||||
def get_profile_pic(self, profile_name: str) -> str:
|
||||
"""Returns filename of profile's last downloaded profile pic."""
|
||||
try:
|
||||
return self.data.get(profile_name, self.PROFILE_PIC)
|
||||
except configparser.Error:
|
||||
return ""
|
||||
|
||||
def set_profile_pic(self, profile_name: str, profile_pic: str):
|
||||
"""Sets filename of profile's last downloaded profile pic."""
|
||||
self._ensure_section(profile_name)
|
||||
self.data.set(profile_name, self.PROFILE_PIC, profile_pic)
|
||||
self._save()
|
||||
+29
-11
@@ -17,7 +17,8 @@ FrozenNodeIterator = NamedTuple('FrozenNodeIterator',
|
||||
('context_username', Optional[str]),
|
||||
('total_index', int),
|
||||
('best_before', Optional[float]),
|
||||
('remaining_data', Optional[Dict])])
|
||||
('remaining_data', Optional[Dict]),
|
||||
('first_node', Optional[Dict])])
|
||||
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."""
|
||||
@@ -26,7 +27,7 @@ FrozenNodeIterator.total_index.__doc__ = """Number of items that have already be
|
||||
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."""
|
||||
|
||||
FrozenNodeIterator.first_node.__doc__ = """Node data of the first item, if an item has been produced."""
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
@@ -89,6 +90,7 @@ class NodeIterator(Iterator[T]):
|
||||
self._best_before = datetime.now() + NodeIterator._shelf_life
|
||||
else:
|
||||
self._data = self._query()
|
||||
self._first_node: Optional[Dict] = None
|
||||
|
||||
def _query(self, after: Optional[str] = None) -> Dict:
|
||||
pagination_variables = {'first': NodeIterator._graphql_page_length} # type: Dict[str, Any]
|
||||
@@ -125,17 +127,21 @@ class NodeIterator(Iterator[T]):
|
||||
except KeyboardInterrupt:
|
||||
self._page_index, self._total_index = page_index, total_index
|
||||
raise
|
||||
return self._node_wrapper(node)
|
||||
item = self._node_wrapper(node)
|
||||
if self._first_node is None:
|
||||
self._first_node = node
|
||||
return item
|
||||
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__()
|
||||
if self._data['edges'] != query_response['edges']:
|
||||
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
|
||||
@@ -157,6 +163,15 @@ class NodeIterator(Iterator[T]):
|
||||
).encode())
|
||||
return base64.urlsafe_b64encode(magic_hash.digest()).decode()
|
||||
|
||||
@property
|
||||
def first_item(self) -> Optional[T]:
|
||||
"""
|
||||
If this iterator has produced any items, returns the first item produced.
|
||||
|
||||
.. versionadded:: 4.8
|
||||
"""
|
||||
return self._node_wrapper(self._first_node) if self._first_node is not None else None
|
||||
|
||||
def freeze(self) -> FrozenNodeIterator:
|
||||
"""Freeze the iterator for later resuming."""
|
||||
remaining_data = None
|
||||
@@ -171,6 +186,7 @@ class NodeIterator(Iterator[T]):
|
||||
total_index=max(self.total_index - 1, 0),
|
||||
best_before=self._best_before.timestamp() if self._best_before else None,
|
||||
remaining_data=remaining_data,
|
||||
first_node=self._first_node,
|
||||
)
|
||||
|
||||
def thaw(self, frozen: FrozenNodeIterator) -> None:
|
||||
@@ -197,6 +213,8 @@ class NodeIterator(Iterator[T]):
|
||||
self._total_index = frozen.total_index
|
||||
self._best_before = datetime.fromtimestamp(frozen.best_before)
|
||||
self._data = frozen.remaining_data
|
||||
if frozen.first_node is not None:
|
||||
self._first_node = frozen.first_node
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
||||
+173
-47
@@ -4,7 +4,8 @@ import re
|
||||
from base64 import b64decode, b64encode
|
||||
from collections import namedtuple
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterable, Iterator, List, Optional, Union
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union
|
||||
|
||||
from . import __version__
|
||||
from .exceptions import *
|
||||
@@ -34,8 +35,8 @@ PostLocation.id.__doc__ = "ID number of location."
|
||||
PostLocation.name.__doc__ = "Location name."
|
||||
PostLocation.slug.__doc__ = "URL friendly variant of location name."
|
||||
PostLocation.has_public_page.__doc__ = "Whether location has a public page."
|
||||
PostLocation.lat.__doc__ = "Latitude (:class:`float`)."
|
||||
PostLocation.lng.__doc__ = "Longitude (:class:`float`)."
|
||||
PostLocation.lat.__doc__ = "Latitude (:class:`float` or None)."
|
||||
PostLocation.lng.__doc__ = "Longitude (:class:`float` or None)."
|
||||
|
||||
|
||||
class Post:
|
||||
@@ -168,6 +169,8 @@ class Post:
|
||||
|
||||
@property
|
||||
def _iphone_struct(self) -> Dict[str, Any]:
|
||||
if not self._context.iphone_support:
|
||||
raise IPhoneSupportDisabledException("iPhone support is disabled.")
|
||||
if not self._context.is_logged_in:
|
||||
raise LoginRequiredException("--login required to access iPhone media info endpoint.")
|
||||
if not self._iphone_struct_:
|
||||
@@ -246,7 +249,7 @@ class Post:
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""URL of the picture / video thumbnail of the post"""
|
||||
if self.typename == "GraphImage" and self._context.is_logged_in:
|
||||
if self.typename == "GraphImage" and self._context.iphone_support 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).rstrip('&')
|
||||
@@ -304,7 +307,7 @@ class Post:
|
||||
node = edge['node']
|
||||
is_video = node['is_video']
|
||||
display_url = node['display_url']
|
||||
if not is_video and self._context.is_logged_in:
|
||||
if not is_video and self._context.iphone_support and self._context.is_logged_in:
|
||||
try:
|
||||
carousel_media = self._iphone_struct['carousel_media']
|
||||
orig_url = carousel_media[idx]['image_versions2']['candidates'][0]['url']
|
||||
@@ -374,13 +377,35 @@ class Post:
|
||||
def video_url(self) -> Optional[str]:
|
||||
"""URL of the video, or None."""
|
||||
if self.is_video:
|
||||
if self._context.is_logged_in:
|
||||
version_urls = []
|
||||
try:
|
||||
version_urls.append(self._field('video_url'))
|
||||
except (InstaloaderException, KeyError, IndexError) as err:
|
||||
self._context.error(f"Warning: Unable to fetch video from graphql of {self}: {err}")
|
||||
if self._context.iphone_support and self._context.is_logged_in:
|
||||
try:
|
||||
url = self._iphone_struct['video_versions'][0]['url']
|
||||
return url
|
||||
version_urls.extend(version['url'] for version in self._iphone_struct['video_versions'])
|
||||
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')
|
||||
self._context.error(f"Unable to fetch high-quality video version of {self}: {err}")
|
||||
version_urls = list(dict.fromkeys(version_urls))
|
||||
if len(version_urls) == 0:
|
||||
return None
|
||||
if len(version_urls) == 1:
|
||||
return version_urls[0]
|
||||
url_candidates: List[Tuple[int, str]] = []
|
||||
for idx, version_url in enumerate(version_urls):
|
||||
try:
|
||||
url_candidates.append((
|
||||
int(self._context.head(version_url, allow_redirects=True).headers.get('Content-Length', 0)),
|
||||
version_url
|
||||
))
|
||||
except (InstaloaderException, KeyError, IndexError) as err:
|
||||
self._context.error(f"Video URL candidate {idx+1}/{len(version_urls)} for {self}: {err}")
|
||||
if not url_candidates:
|
||||
# All candidates fail: Fallback to default URL and handle errors later at the actual download attempt
|
||||
return version_urls[0]
|
||||
url_candidates.sort()
|
||||
return url_candidates[-1][1]
|
||||
return None
|
||||
|
||||
@property
|
||||
@@ -553,10 +578,10 @@ class Post:
|
||||
return None
|
||||
location_id = int(loc['id'])
|
||||
if any(k not in loc for k in ('name', 'slug', 'has_public_page', 'lat', 'lng')):
|
||||
loc = self._context.get_json("explore/locations/{0}/".format(location_id),
|
||||
params={'__a': 1})['graphql']['location']
|
||||
loc.update(self._context.get_json("explore/locations/{0}/".format(location_id),
|
||||
params={'__a': 1})['native_location_data']['location_info'])
|
||||
self._location = PostLocation(location_id, loc['name'], loc['slug'], loc['has_public_page'],
|
||||
loc['lat'], loc['lng'])
|
||||
loc.get('lat'), loc.get('lng'))
|
||||
return self._location
|
||||
|
||||
|
||||
@@ -693,6 +718,8 @@ class Profile:
|
||||
|
||||
@property
|
||||
def _iphone_struct(self) -> Dict[str, Any]:
|
||||
if not self._context.iphone_support:
|
||||
raise IPhoneSupportDisabledException("iPhone support is disabled.")
|
||||
if not self._context.is_logged_in:
|
||||
raise LoginRequiredException("--login required to access iPhone profile info endpoint.")
|
||||
if not self._iphone_struct_:
|
||||
@@ -793,14 +820,13 @@ class Profile:
|
||||
def has_public_story(self) -> bool:
|
||||
if not self._has_public_story:
|
||||
self._obtain_metadata()
|
||||
# query not rate limited if invoked anonymously:
|
||||
with self._context.anonymous_copy() as anonymous_context:
|
||||
data = anonymous_context.graphql_query('9ca88e465c3f866a76f7adee3871bdd8',
|
||||
{'user_id': self.userid, 'include_chaining': False,
|
||||
'include_reel': False, 'include_suggested_users': False,
|
||||
'include_logged_out_extras': True,
|
||||
'include_highlight_reels': False},
|
||||
'https://www.instagram.com/{}/'.format(self.username))
|
||||
# query rate might be limited:
|
||||
data = self._context.graphql_query('9ca88e465c3f866a76f7adee3871bdd8',
|
||||
{'user_id': self.userid, 'include_chaining': False,
|
||||
'include_reel': False, 'include_suggested_users': False,
|
||||
'include_logged_out_extras': True,
|
||||
'include_highlight_reels': False},
|
||||
'https://www.instagram.com/{}/'.format(self.username))
|
||||
self._has_public_story = data['data']['user']['has_public_story']
|
||||
assert self._has_public_story is not None
|
||||
return self._has_public_story
|
||||
@@ -836,7 +862,7 @@ class Profile:
|
||||
|
||||
.. versionchanged:: 4.2.1
|
||||
Require being logged in for HD version (as required by Instagram)."""
|
||||
if self._context.is_logged_in:
|
||||
if self._context.iphone_support and self._context.is_logged_in:
|
||||
try:
|
||||
return self._iphone_struct['hd_profile_pic_url_info']['url']
|
||||
except (InstaloaderException, KeyError) as err:
|
||||
@@ -1023,6 +1049,8 @@ class StoryItem:
|
||||
|
||||
@property
|
||||
def _iphone_struct(self) -> Dict[str, Any]:
|
||||
if not self._context.iphone_support:
|
||||
raise IPhoneSupportDisabledException("iPhone support is disabled.")
|
||||
if not self._context.is_logged_in:
|
||||
raise LoginRequiredException("--login required to access iPhone media info endpoint.")
|
||||
if not self._iphone_struct_:
|
||||
@@ -1081,7 +1109,7 @@ class StoryItem:
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""URL of the picture / video thumbnail of the StoryItem"""
|
||||
if self.typename == "GraphStoryImage" and self._context.is_logged_in:
|
||||
if self.typename == "GraphStoryImage" and self._context.iphone_support 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).rstrip('&')
|
||||
@@ -1104,7 +1132,35 @@ class StoryItem:
|
||||
def video_url(self) -> Optional[str]:
|
||||
"""URL of the video, or None."""
|
||||
if self.is_video:
|
||||
return self._node['video_resources'][-1]['src']
|
||||
version_urls = []
|
||||
try:
|
||||
version_urls.append(self._node['video_resources'][-1]['src'])
|
||||
except (InstaloaderException, KeyError, IndexError) as err:
|
||||
self._context.error(f"Warning: Unable to fetch video from graphql of {self}: {err}")
|
||||
if self._context.iphone_support and self._context.is_logged_in:
|
||||
try:
|
||||
version_urls.extend(version['url'] for version in self._iphone_struct['video_versions'])
|
||||
except (InstaloaderException, KeyError, IndexError) as err:
|
||||
self._context.error(f"Unable to fetch high-quality video version of {self}: {err}")
|
||||
version_urls = list(dict.fromkeys(version_urls))
|
||||
if len(version_urls) == 0:
|
||||
return None
|
||||
if len(version_urls) == 1:
|
||||
return version_urls[0]
|
||||
url_candidates: List[Tuple[int, str]] = []
|
||||
for idx, version_url in enumerate(version_urls):
|
||||
try:
|
||||
url_candidates.append((
|
||||
int(self._context.head(version_url, allow_redirects=True).headers.get('Content-Length', 0)),
|
||||
version_url
|
||||
))
|
||||
except (InstaloaderException, KeyError, IndexError) as err:
|
||||
self._context.error(f"Video URL candidate {idx+1}/{len(version_urls)} for {self}: {err}")
|
||||
if not url_candidates:
|
||||
# All candidates fail: Fallback to default URL and handle errors later at the actual download attempt
|
||||
return version_urls[0]
|
||||
url_candidates.sort()
|
||||
return url_candidates[-1][1]
|
||||
return None
|
||||
|
||||
|
||||
@@ -1503,7 +1559,7 @@ class TopSearchResults:
|
||||
place = location.get('place', {})
|
||||
slug = place.get('slug')
|
||||
loc = place.get('location', {})
|
||||
yield PostLocation(int(loc['pk']), loc['name'], slug, None, loc['lat'], loc['lng'])
|
||||
yield PostLocation(int(loc['pk']), loc['name'], slug, None, loc.get('lat'), loc.get('lng'))
|
||||
|
||||
def get_hashtag_strings(self) -> Iterator[str]:
|
||||
"""
|
||||
@@ -1533,9 +1589,69 @@ class TopSearchResults:
|
||||
return self._searchstring
|
||||
|
||||
|
||||
class TitlePic:
|
||||
def __init__(self, profile: Optional[Profile], target: Union[str, Path], typename: str,
|
||||
filename: str, date_utc: Optional[datetime]):
|
||||
self._profile = profile
|
||||
self._target = target
|
||||
self._typename = typename
|
||||
self._filename = filename
|
||||
self._date_utc = date_utc
|
||||
|
||||
@property
|
||||
def profile(self) -> Union[str, Path]:
|
||||
return self._profile.username.lower() if self._profile is not None else self._target
|
||||
|
||||
@property
|
||||
def owner_username(self) -> Union[str, Path]:
|
||||
return self.profile
|
||||
|
||||
@property
|
||||
def owner_id(self) -> Union[str, Path]:
|
||||
return str(self._profile.userid) if self._profile is not None else self._target
|
||||
|
||||
@property
|
||||
def target(self) -> Union[str, Path]:
|
||||
return self._target
|
||||
|
||||
@property
|
||||
def typename(self) -> str:
|
||||
return self._typename
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
return self._filename
|
||||
|
||||
@property
|
||||
def date_utc(self) -> Optional[datetime]:
|
||||
return self._date_utc
|
||||
|
||||
@property
|
||||
def date(self) -> Optional[datetime]:
|
||||
return self.date_utc
|
||||
|
||||
@property
|
||||
def date_local(self) -> Optional[datetime]:
|
||||
return self._date_utc.astimezone() if self._date_utc is not None else None
|
||||
|
||||
|
||||
JsonExportable = Union[Post, Profile, StoryItem, Hashtag, FrozenNodeIterator]
|
||||
|
||||
|
||||
def get_json_structure(structure: JsonExportable) -> dict:
|
||||
"""Returns Instaloader JSON structure for a :class:`Post`, :class:`Profile`, :class:`StoryItem`, :class:`Hashtag`
|
||||
or :class:`FrozenNodeIterator` so that it can be loaded by :func:`load_structure`.
|
||||
|
||||
:param structure: :class:`Post`, :class:`Profile`, :class:`StoryItem` or :class:`Hashtag`
|
||||
|
||||
.. versionadded:: 4.8
|
||||
"""
|
||||
return {
|
||||
'node': structure._asdict(),
|
||||
'instaloader': {'version': __version__, 'node_type': structure.__class__.__name__}
|
||||
}
|
||||
|
||||
|
||||
def save_structure_to_file(structure: JsonExportable, filename: str) -> None:
|
||||
"""Saves a :class:`Post`, :class:`Profile`, :class:`StoryItem`, :class:`Hashtag` or :class:`FrozenNodeIterator` to a
|
||||
'.json' or '.json.xz' file such that it can later be loaded by :func:`load_structure_from_file`.
|
||||
@@ -1546,8 +1662,7 @@ def save_structure_to_file(structure: JsonExportable, filename: str) -> None:
|
||||
:param structure: :class:`Post`, :class:`Profile`, :class:`StoryItem` or :class:`Hashtag`
|
||||
:param filename: Filename, ends in '.json' or '.json.xz'
|
||||
"""
|
||||
json_structure = {'node': structure._asdict(),
|
||||
'instaloader': {'version': __version__, 'node_type': structure.__class__.__name__}}
|
||||
json_structure = get_json_structure(structure)
|
||||
compress = filename.endswith('.xz')
|
||||
if compress:
|
||||
with lzma.open(filename, 'wt', check=lzma.CHECK_NONE) as fp:
|
||||
@@ -1557,6 +1672,36 @@ def save_structure_to_file(structure: JsonExportable, filename: str) -> None:
|
||||
json.dump(json_structure, fp=fp, indent=4, sort_keys=True)
|
||||
|
||||
|
||||
def load_structure(context: InstaloaderContext, json_structure: dict) -> JsonExportable:
|
||||
"""Loads a :class:`Post`, :class:`Profile`, :class:`StoryItem`, :class:`Hashtag` or :class:`FrozenNodeIterator` from
|
||||
a json structure.
|
||||
|
||||
:param context: :attr:`Instaloader.context` linked to the new object, used for additional queries if neccessary.
|
||||
:param json_structure: Instaloader JSON structure
|
||||
|
||||
.. versionadded:: 4.8
|
||||
"""
|
||||
if 'node' in json_structure and 'instaloader' in json_structure and \
|
||||
'node_type' in json_structure['instaloader']:
|
||||
node_type = json_structure['instaloader']['node_type']
|
||||
if node_type == "Post":
|
||||
return Post(context, json_structure['node'])
|
||||
elif node_type == "Profile":
|
||||
return Profile(context, json_structure['node'])
|
||||
elif node_type == "StoryItem":
|
||||
return StoryItem(context, json_structure['node'])
|
||||
elif node_type == "Hashtag":
|
||||
return Hashtag(context, json_structure['node'])
|
||||
elif node_type == "FrozenNodeIterator":
|
||||
if not 'first_node' in json_structure['node']:
|
||||
json_structure['node']['first_node'] = None
|
||||
return FrozenNodeIterator(**json_structure['node'])
|
||||
elif 'shortcode' in json_structure:
|
||||
# Post JSON created with Instaloader v3
|
||||
return Post.from_shortcode(context, json_structure['shortcode'])
|
||||
raise InvalidArgumentException("Passed json structure is not an Instaloader JSON")
|
||||
|
||||
|
||||
def load_structure_from_file(context: InstaloaderContext, filename: str) -> JsonExportable:
|
||||
"""Loads a :class:`Post`, :class:`Profile`, :class:`StoryItem`, :class:`Hashtag` or :class:`FrozenNodeIterator` from
|
||||
a '.json' or '.json.xz' file that has been saved by :func:`save_structure_to_file`.
|
||||
@@ -1571,23 +1716,4 @@ def load_structure_from_file(context: InstaloaderContext, filename: str) -> Json
|
||||
fp = open(filename, 'rt')
|
||||
json_structure = json.load(fp)
|
||||
fp.close()
|
||||
if 'node' in json_structure and 'instaloader' in json_structure and \
|
||||
'node_type' in json_structure['instaloader']:
|
||||
node_type = json_structure['instaloader']['node_type']
|
||||
if node_type == "Post":
|
||||
return Post(context, json_structure['node'])
|
||||
elif node_type == "Profile":
|
||||
return Profile(context, json_structure['node'])
|
||||
elif node_type == "StoryItem":
|
||||
return StoryItem(context, json_structure['node'])
|
||||
elif node_type == "Hashtag":
|
||||
return Hashtag(context, json_structure['node'])
|
||||
elif node_type == "FrozenNodeIterator":
|
||||
return FrozenNodeIterator(**json_structure['node'])
|
||||
else:
|
||||
raise InvalidArgumentException("{}: Not an Instaloader JSON.".format(filename))
|
||||
elif 'shortcode' in json_structure:
|
||||
# Post JSON created with Instaloader v3
|
||||
return Post.from_shortcode(context, json_structure['shortcode'])
|
||||
else:
|
||||
raise InvalidArgumentException("{}: Not an Instaloader JSON.".format(filename))
|
||||
return load_structure(context, json_structure)
|
||||
|
||||
Reference in New Issue
Block a user