mirror of
https://github.com/instaloader/instaloader.git
synced 2026-10-31 13:35:30 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bba7d5131c | ||
|
|
f6731566cd | ||
|
|
cd13211603 | ||
|
|
8635a576f0 | ||
|
|
8f594a3a30 | ||
|
|
0f11ef1bdd | ||
|
|
a4bb353421 | ||
|
|
dfdd744804 | ||
|
|
9fb112faa8 | ||
|
|
f85e49d521 | ||
|
|
6c4da39372 | ||
|
|
a2d756b177 | ||
|
|
e15d67c065 | ||
|
|
640066dd60 | ||
|
|
672b0b1840 | ||
|
|
43c521afa9 |
+4
-2
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
.. |downloads| image:: https://pepy.tech/badge/instaloader/month
|
.. |downloads| image:: https://pepy.tech/badge/instaloader/month
|
||||||
:alt: PyPI Download Count
|
:alt: PyPI Download Count
|
||||||
:target: https://pepy.tech/project/instaloader/month
|
:target: https://pepy.tech/project/instaloader
|
||||||
|
|
||||||
.. badges-end
|
.. badges-end
|
||||||
|
|
||||||
@@ -84,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.
|
||||||
|
|
||||||
|
|||||||
+22
-3
@@ -220,12 +220,20 @@ How to Download
|
|||||||
used to save the information to resume an interrupted download. The default
|
used to save the information to resume an interrupted download. The default
|
||||||
prefix is ``iterator``.
|
prefix is ``iterator``.
|
||||||
|
|
||||||
Resuming an interrupted download is supported for most, but not all targets.
|
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
|
JSON files with resume information are always compressed, regardless of
|
||||||
:option:`--no-compress-json`.
|
:option:`--no-compress-json`.
|
||||||
|
|
||||||
This feature is turned off entirely with :option:`--no-resume`.
|
|
||||||
|
|
||||||
.. versionadded:: 4.5
|
.. versionadded:: 4.5
|
||||||
|
|
||||||
.. option:: --no-resume
|
.. option:: --no-resume
|
||||||
@@ -255,6 +263,17 @@ How to Download
|
|||||||
.. versionchanged:: 4.6
|
.. versionchanged:: 4.6
|
||||||
Enabled this option by default with a timeout of 300 seconds.
|
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
|
||||||
^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
|||||||
@@ -47,3 +47,5 @@ Exceptions
|
|||||||
|
|
||||||
|
|
||||||
.. autoexception:: TooManyRequestsException
|
.. autoexception:: TooManyRequestsException
|
||||||
|
|
||||||
|
.. autoexception:: AbortDownloadException
|
||||||
|
|||||||
@@ -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.6.1'
|
__version__ = '4.7rc1'
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+20
-6
@@ -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)
|
||||||
@@ -369,6 +379,9 @@ def main():
|
|||||||
g_how.add_argument('--commit-mode', action='store_true', help=SUPPRESS)
|
g_how.add_argument('--commit-mode', action='store_true', help=SUPPRESS)
|
||||||
g_how.add_argument('--request-timeout', metavar='N', type=float, default=300.0,
|
g_how.add_argument('--request-timeout', metavar='N', type=float, default=300.0,
|
||||||
help='Seconds to wait before timing out a connection request. Defaults to 300.')
|
help='Seconds to wait before timing out a connection request. Defaults to 300.')
|
||||||
|
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_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',
|
||||||
@@ -427,7 +440,8 @@ def main():
|
|||||||
request_timeout=args.request_timeout,
|
request_timeout=args.request_timeout,
|
||||||
resume_prefix=resume_prefix,
|
resume_prefix=resume_prefix,
|
||||||
check_resume_bbd=not args.use_aged_resume_files,
|
check_resume_bbd=not args.use_aged_resume_files,
|
||||||
slide=args.slide)
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+106
-34
@@ -161,6 +161,7 @@ class Instaloader:
|
|||||||
:param resume_prefix: :option:`--resume-prefix`, or None for :option:`--no-resume`.
|
: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 check_resume_bbd: Whether to check the date of expiry of resume files and reject them if expired.
|
||||||
:param slide: :option:`--slide`
|
:param slide: :option:`--slide`
|
||||||
|
:param fatal_status_codes: :option:`--abort-on`
|
||||||
|
|
||||||
.. attribute:: context
|
.. attribute:: context
|
||||||
|
|
||||||
@@ -187,10 +188,11 @@ class Instaloader:
|
|||||||
rate_controller: Optional[Callable[[InstaloaderContext], RateController]] = None,
|
rate_controller: Optional[Callable[[InstaloaderContext], RateController]] = None,
|
||||||
resume_prefix: Optional[str] = "iterator",
|
resume_prefix: Optional[str] = "iterator",
|
||||||
check_resume_bbd: bool = True,
|
check_resume_bbd: bool = True,
|
||||||
slide: Optional[str] = None):
|
slide: Optional[str] = None,
|
||||||
|
fatal_status_codes: Optional[List[int]] = None):
|
||||||
|
|
||||||
self.context = InstaloaderContext(sleep, quiet, user_agent, max_connection_attempts,
|
self.context = InstaloaderContext(sleep, quiet, user_agent, max_connection_attempts,
|
||||||
request_timeout, rate_controller)
|
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}"
|
||||||
@@ -256,7 +258,8 @@ class Instaloader:
|
|||||||
request_timeout=self.context.request_timeout,
|
request_timeout=self.context.request_timeout,
|
||||||
resume_prefix=self.resume_prefix,
|
resume_prefix=self.resume_prefix,
|
||||||
check_resume_bbd=self.check_resume_bbd,
|
check_resume_bbd=self.check_resume_bbd,
|
||||||
slide=self.slide)
|
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
|
||||||
@@ -332,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:
|
||||||
@@ -551,6 +583,27 @@ 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_template = os.path.join(dirname, self.format_filename(post, target=target))
|
filename_template = os.path.join(dirname, self.format_filename(post, target=target))
|
||||||
filename = self.__prepare_filename(filename_template, lambda: post.url)
|
filename = self.__prepare_filename(filename_template, lambda: post.url)
|
||||||
@@ -559,37 +612,47 @@ class Instaloader:
|
|||||||
downloaded = True
|
downloaded = True
|
||||||
if post.typename == 'GraphSidecar':
|
if post.typename == 'GraphSidecar':
|
||||||
if self.download_pictures or self.download_videos:
|
if self.download_pictures or self.download_videos:
|
||||||
for edge_number, sidecar_node in enumerate(
|
if not _all_already_downloaded(
|
||||||
post.get_sidecar_nodes(self.slide_start, self.slide_end),
|
filename_template, enumerate(
|
||||||
start=post.mediacount if self.slide_start < 0 else self.slide_start + 1
|
(post.get_is_videos()[i]
|
||||||
|
for i in range(self.slide_start % post.mediacount, self.slide_end % post.mediacount + 1)),
|
||||||
|
start=self.slide_start % post.mediacount + 1
|
||||||
|
)
|
||||||
):
|
):
|
||||||
if self.download_pictures and (not sidecar_node.is_video or self.download_video_thumbnails):
|
for edge_number, sidecar_node in enumerate(
|
||||||
suffix = str(edge_number)
|
post.get_sidecar_nodes(self.slide_start, self.slide_end),
|
||||||
|
start=self.slide_start % post.mediacount + 1
|
||||||
|
):
|
||||||
|
suffix = str(edge_number) # type: Optional[str]
|
||||||
if '{filename}' in self.filename_pattern:
|
if '{filename}' in self.filename_pattern:
|
||||||
suffix = ''
|
suffix = None
|
||||||
# pylint:disable=cell-var-from-loop
|
if self.download_pictures and (not sidecar_node.is_video or self.download_video_thumbnails):
|
||||||
filename = self.__prepare_filename(filename_template, lambda: sidecar_node.display_url)
|
# pylint:disable=cell-var-from-loop
|
||||||
# Download sidecar picture or video thumbnail (--no-pictures implies --no-video-thumbnails)
|
sidecar_filename = self.__prepare_filename(filename_template,
|
||||||
downloaded &= self.download_pic(filename=filename, url=sidecar_node.display_url,
|
lambda: sidecar_node.display_url)
|
||||||
mtime=post.date_local, filename_suffix=suffix)
|
# Download sidecar picture or video thumbnail (--no-pictures implies --no-video-thumbnails)
|
||||||
if sidecar_node.is_video and self.download_videos:
|
downloaded &= self.download_pic(filename=sidecar_filename, url=sidecar_node.display_url,
|
||||||
suffix = str(edge_number)
|
mtime=post.date_local, filename_suffix=suffix)
|
||||||
if '{filename}' in self.filename_pattern:
|
if sidecar_node.is_video and self.download_videos:
|
||||||
suffix = ''
|
# pylint:disable=cell-var-from-loop
|
||||||
# pylint:disable=cell-var-from-loop
|
sidecar_filename = self.__prepare_filename(filename_template,
|
||||||
filename = self.__prepare_filename(filename_template, lambda: sidecar_node.video_url)
|
lambda: sidecar_node.video_url)
|
||||||
# Download sidecar video if desired
|
# Download sidecar video if desired
|
||||||
downloaded &= self.download_pic(filename=filename, url=sidecar_node.video_url,
|
downloaded &= self.download_pic(filename=sidecar_filename, url=sidecar_node.video_url,
|
||||||
mtime=post.date_local, filename_suffix=suffix)
|
mtime=post.date_local, filename_suffix=suffix)
|
||||||
|
else:
|
||||||
|
downloaded = False
|
||||||
elif post.typename == 'GraphImage':
|
elif post.typename == 'GraphImage':
|
||||||
# Download picture
|
# Download picture
|
||||||
if self.download_pictures:
|
if self.download_pictures:
|
||||||
downloaded = self.download_pic(filename=filename, url=post.url, mtime=post.date_local)
|
downloaded = (not _already_downloaded(filename + ".jpg") and
|
||||||
|
self.download_pic(filename=filename, url=post.url, mtime=post.date_local))
|
||||||
elif post.typename == 'GraphVideo':
|
elif post.typename == 'GraphVideo':
|
||||||
# Download video thumbnail (--no-pictures implies --no-video-thumbnails)
|
# Download video thumbnail (--no-pictures implies --no-video-thumbnails)
|
||||||
if self.download_pictures and self.download_video_thumbnails:
|
if self.download_pictures and self.download_video_thumbnails:
|
||||||
with self.context.error_catcher("Video thumbnail of {}".format(post)):
|
with self.context.error_catcher("Video thumbnail of {}".format(post)):
|
||||||
downloaded = self.download_pic(filename=filename, url=post.url, mtime=post.date_local)
|
downloaded = (not _already_downloaded(filename + ".jpg") and
|
||||||
|
self.download_pic(filename=filename, url=post.url, mtime=post.date_local))
|
||||||
else:
|
else:
|
||||||
self.context.error("Warning: {0} has unknown typename: {1}".format(post, post.typename))
|
self.context.error("Warning: {0} has unknown typename: {1}".format(post, post.typename))
|
||||||
|
|
||||||
@@ -600,7 +663,8 @@ class Instaloader:
|
|||||||
|
|
||||||
# Download video if desired
|
# Download video if desired
|
||||||
if post.is_video and self.download_videos:
|
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:
|
||||||
@@ -693,17 +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_template = os.path.join(dirname, self.format_filename(item, target=target))
|
filename_template = os.path.join(dirname, self.format_filename(item, target=target))
|
||||||
filename = self.__prepare_filename(filename_template, lambda: item.url)
|
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:
|
||||||
filename = self.__prepare_filename(filename_template, lambda: str(item.video_url))
|
filename = self.__prepare_filename(filename_template, lambda: str(item.video_url))
|
||||||
downloaded |= self.download_pic(filename=filename, url=item.video_url, mtime=date_local)
|
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:
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ 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: float = 300.0,
|
max_connection_attempts: int = 3, request_timeout: float = 300.0,
|
||||||
rate_controller: Optional[Callable[["InstaloaderContext"], "RateController"]] = None):
|
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
|
||||||
@@ -74,6 +75,9 @@ class InstaloaderContext:
|
|||||||
# Can be set to True for testing, disables supression of InstaloaderContext._error_catcher
|
# Can be set to True for testing, disables supression of InstaloaderContext._error_catcher
|
||||||
self.raise_all_errors = False
|
self.raise_all_errors = False
|
||||||
|
|
||||||
|
# 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]
|
||||||
|
|
||||||
@@ -316,6 +320,11 @@ class InstaloaderContext:
|
|||||||
if is_other_query:
|
if is_other_query:
|
||||||
self._rate_controller.wait_before_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))
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import os
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from lzma import LZMAError
|
from lzma import LZMAError
|
||||||
from typing import Any, Callable, Dict, Iterator, NamedTuple, Optional, Tuple, TypeVar
|
from typing import Any, Callable, Dict, Iterable, Iterator, NamedTuple, Optional, Tuple, TypeVar
|
||||||
|
|
||||||
from .exceptions import InvalidArgumentException, QueryReturnedBadRequestException
|
from .exceptions import AbortDownloadException, InvalidArgumentException, QueryReturnedBadRequestException
|
||||||
from .instaloadercontext import InstaloaderContext
|
from .instaloadercontext import InstaloaderContext
|
||||||
|
|
||||||
FrozenNodeIterator = NamedTuple('FrozenNodeIterator',
|
FrozenNodeIterator = NamedTuple('FrozenNodeIterator',
|
||||||
@@ -204,14 +204,15 @@ class NodeIterator(Iterator[T]):
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def resumable_iteration(context: InstaloaderContext,
|
def resumable_iteration(context: InstaloaderContext,
|
||||||
iterator: Iterator,
|
iterator: Iterable,
|
||||||
load: Callable[[InstaloaderContext, str], Any],
|
load: Callable[[InstaloaderContext, str], Any],
|
||||||
save: Callable[[FrozenNodeIterator, str], None],
|
save: Callable[[FrozenNodeIterator, str], None],
|
||||||
format_path: Callable[[str], str],
|
format_path: Callable[[str], str],
|
||||||
check_bbd: bool = True,
|
check_bbd: bool = True,
|
||||||
enabled: bool = True) -> Iterator[Tuple[bool, int]]:
|
enabled: bool = True) -> Iterator[Tuple[bool, int]]:
|
||||||
"""
|
"""
|
||||||
High-level context manager to handle a resumable iteration that can be interrupted with a KeyboardInterrupt.
|
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
|
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::
|
state when interrupted, and delete the resume file upon completion::
|
||||||
@@ -239,6 +240,9 @@ def resumable_iteration(context: InstaloaderContext,
|
|||||||
:param format_path: Returns the path to the resume file for the given magic.
|
: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 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.
|
: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):
|
if not enabled or not isinstance(iterator, NodeIterator):
|
||||||
yield False, 0
|
yield False, 0
|
||||||
@@ -262,7 +266,7 @@ def resumable_iteration(context: InstaloaderContext,
|
|||||||
context.error("Warning: Not resuming from {}: {}".format(resume_file_path, exc))
|
context.error("Warning: Not resuming from {}: {}".format(resume_file_path, exc))
|
||||||
try:
|
try:
|
||||||
yield is_resuming, start_index
|
yield is_resuming, start_index
|
||||||
except KeyboardInterrupt:
|
except (KeyboardInterrupt, AbortDownloadException):
|
||||||
if os.path.dirname(resume_file_path):
|
if os.path.dirname(resume_file_path):
|
||||||
os.makedirs(os.path.dirname(resume_file_path), exist_ok=True)
|
os.makedirs(os.path.dirname(resume_file_path), exist_ok=True)
|
||||||
save(iterator.freeze(), resume_file_path)
|
save(iterator.freeze(), resume_file_path)
|
||||||
|
|||||||
+31
-10
@@ -4,7 +4,7 @@ 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 *
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -264,6 +272,17 @@ class Post:
|
|||||||
return len(edges)
|
return len(edges)
|
||||||
return 1
|
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]:
|
def get_sidecar_nodes(self, start=0, end=-1) -> Iterator[PostSidecarNode]:
|
||||||
"""
|
"""
|
||||||
Sidecar nodes of a Post with typename==GraphSidecar.
|
Sidecar nodes of a Post with typename==GraphSidecar.
|
||||||
@@ -273,13 +292,13 @@ class Post:
|
|||||||
"""
|
"""
|
||||||
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):
|
|
||||||
# video_url is only present in full metadata, issue #558.
|
|
||||||
edges = self._full_metadata['edge_sidecar_to_children']['edges']
|
|
||||||
if end < 0:
|
if end < 0:
|
||||||
end = len(edges)-1
|
end = len(edges)-1
|
||||||
if start < 0:
|
if start < 0:
|
||||||
start = len(edges)-1
|
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.
|
||||||
|
edges = self._full_metadata['edge_sidecar_to_children']['edges']
|
||||||
for idx, edge in enumerate(edges):
|
for idx, edge in enumerate(edges):
|
||||||
if start <= idx <= end:
|
if start <= idx <= end:
|
||||||
node = edge['node']
|
node = edge['node']
|
||||||
@@ -407,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']),
|
||||||
@@ -447,16 +469,15 @@ class Post:
|
|||||||
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 NodeIterator(
|
|
||||||
self._context,
|
self._context,
|
||||||
'97b41c52301f77ce508f55e66d17620e',
|
'97b41c52301f77ce508f55e66d17620e',
|
||||||
lambda d: d['data']['shortcode_media']['edge_media_to_parent_comment'],
|
lambda d: d['data']['shortcode_media']['edge_media_to_parent_comment'],
|
||||||
@@ -835,7 +856,7 @@ class Profile:
|
|||||||
self._obtain_metadata()
|
self._obtain_metadata()
|
||||||
return NodeIterator(
|
return NodeIterator(
|
||||||
self._context,
|
self._context,
|
||||||
'472f257a40c653c64c666ce877d59d2b',
|
'003056d32c2554def87228bc3fd9668a',
|
||||||
lambda d: d['data']['user']['edge_owner_to_timeline_media'],
|
lambda d: d['data']['user']['edge_owner_to_timeline_media'],
|
||||||
lambda n: Post(self._context, n, self),
|
lambda n: Post(self._context, n, self),
|
||||||
{'id': self.userid},
|
{'id': self.userid},
|
||||||
|
|||||||
Reference in New Issue
Block a user