mirror of
https://github.com/instaloader/instaloader.git
synced 2026-10-23 08:59:38 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1387d396dd | ||
|
|
4ee867c61b | ||
|
|
edba6959d9 | ||
|
|
08327c4117 | ||
|
|
4ab0fea82a | ||
|
|
a735b02bac | ||
|
|
be5d02ef3b | ||
|
|
1ab9e44104 | ||
|
|
6212d22e26 | ||
|
|
aa859b9f54 | ||
|
|
ffda2265bd | ||
|
|
e753406a6c | ||
|
|
30555b576c |
+1
-1
@@ -8,7 +8,7 @@ install:
|
|||||||
- pip install pylint requests
|
- pip install pylint requests
|
||||||
- pip install -r docs/requirements.txt
|
- pip install -r docs/requirements.txt
|
||||||
script:
|
script:
|
||||||
- python3 -m pylint -r n -d bad-whitespace,missing-docstring,too-many-arguments,locally-disabled,line-too-long,too-many-public-methods,too-many-lines,too-many-instance-attributes,too-many-locals,too-many-branches,too-many-statements,inconsistent-return-statements,invalid-name,wildcard-import,unused-wildcard-import,no-else-return instaloader
|
- python3 -m pylint -r n -d bad-whitespace,missing-docstring,too-many-arguments,locally-disabled,line-too-long,too-many-public-methods,too-many-lines,too-many-instance-attributes,too-many-locals,too-many-branches,too-many-statements,inconsistent-return-statements,invalid-name,wildcard-import,unused-wildcard-import,no-else-return,cyclic-import,unnecessary-pass instaloader
|
||||||
- make -C docs html SPHINXOPTS="-W -n"
|
- make -C docs html SPHINXOPTS="-W -n"
|
||||||
deploy:
|
deploy:
|
||||||
- provider: pypi
|
- provider: pypi
|
||||||
|
|||||||
@@ -188,6 +188,10 @@ Exceptions
|
|||||||
|
|
||||||
.. autoexception:: LoginRequiredException
|
.. autoexception:: LoginRequiredException
|
||||||
|
|
||||||
|
.. autoexception:: TwoFactorAuthRequiredException
|
||||||
|
|
||||||
|
.. versionadded:: 4.2
|
||||||
|
|
||||||
.. autoexception:: InvalidArgumentException
|
.. autoexception:: InvalidArgumentException
|
||||||
|
|
||||||
.. autoexception:: BadResponseException
|
.. autoexception:: BadResponseException
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ Instaloader supports the following targets:
|
|||||||
- ``"#hashtag"``
|
- ``"#hashtag"``
|
||||||
Posts with a certain **hashtag** (the quotes are usually necessary),
|
Posts with a certain **hashtag** (the quotes are usually necessary),
|
||||||
|
|
||||||
|
- ``%location id``
|
||||||
|
Posts tagged with a given location; the location ID is the numerical ID
|
||||||
|
Instagram labels a location with (e.g.
|
||||||
|
\https://www.instagram.com/explore/locations/**362629379**/plymouth-naval-memorial/).
|
||||||
|
|
||||||
|
.. versionadded:: 4.2
|
||||||
|
|
||||||
- ``:stories``
|
- ``:stories``
|
||||||
The currently-visible **stories** of your followees (requires
|
The currently-visible **stories** of your followees (requires
|
||||||
:option:`--login`),
|
:option:`--login`),
|
||||||
@@ -126,7 +133,7 @@ Filename Specification
|
|||||||
^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
For each target, Instaloader creates a directory named after the target,
|
For each target, Instaloader creates a directory named after the target,
|
||||||
i.e. ``profile``, ``#hashtag``, ``:feed``, etc. and therein saves the
|
i.e. ``profile``, ``#hashtag``, ``%location id``, ``:feed``, etc. and therein saves the
|
||||||
posts in files named after the post's timestamp.
|
posts in files named after the post's timestamp.
|
||||||
|
|
||||||
:option:`--dirname-pattern` allows to configure the directory name of each
|
:option:`--dirname-pattern` allows to configure the directory name of each
|
||||||
|
|||||||
+10
-2
@@ -8,7 +8,7 @@ Instaloader is invoked with::
|
|||||||
$ instaloader [options] target [target ...]
|
$ instaloader [options] target [target ...]
|
||||||
|
|
||||||
where ``target`` is a ``profile``, a ``"#hashtag"``, ``@profile`` (all profiles
|
where ``target`` is a ``profile``, a ``"#hashtag"``, ``@profile`` (all profiles
|
||||||
that *profile* is following), or if logged in ``:feed`` (pictures from your
|
that *profile* is following), ``%location ID``, or if logged in ``:feed`` (pictures from your
|
||||||
feed), ``:stories`` (stories of your followees) or ``:saved`` (collection of
|
feed), ``:stories`` (stories of your followees) or ``:saved`` (collection of
|
||||||
posts marked as saved).
|
posts marked as saved).
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ Which Posts to Download
|
|||||||
.. option:: --count COUNT, -c
|
.. option:: --count COUNT, -c
|
||||||
|
|
||||||
Do not attempt to download more than COUNT posts. Applies only to
|
Do not attempt to download more than COUNT posts. Applies only to
|
||||||
``#hashtag`` and ``:feed``.
|
``#hashtag``, ``%location id``, and ``:feed``.
|
||||||
|
|
||||||
|
|
||||||
Login (Download Private Profiles)
|
Login (Download Private Profiles)
|
||||||
@@ -230,6 +230,14 @@ How to Download
|
|||||||
to ``3``. If a connection fails, it can be manually skipped by hitting
|
to ``3``. If a connection fails, it can be manually skipped by hitting
|
||||||
:kbd:`Control-c`. Set this to ``0`` to retry infinitely.
|
:kbd:`Control-c`. Set this to ``0`` to retry infinitely.
|
||||||
|
|
||||||
|
.. option:: --commit-mode
|
||||||
|
|
||||||
|
Tries to ensure downloaded images avoid corruption in case of unexpected
|
||||||
|
interruption. If the last picture is corrupted, Instaloader will fix the
|
||||||
|
picture the next time it is run.
|
||||||
|
|
||||||
|
.. versionadded:: 4.2
|
||||||
|
|
||||||
Miscellaneous Options
|
Miscellaneous Options
|
||||||
^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import typing
|
||||||
|
|
||||||
import sphinx_bootstrap_theme
|
import sphinx_bootstrap_theme
|
||||||
|
|
||||||
@@ -374,6 +375,7 @@ html_context = {'current_release': current_release, 'current_release_date': curr
|
|||||||
|
|
||||||
|
|
||||||
def setup(app):
|
def setup(app):
|
||||||
|
typing.TYPE_CHECKING = True
|
||||||
app.add_stylesheet("style.css")
|
app.add_stylesheet("style.css")
|
||||||
app.connect('autodoc-process-signature', sphinx_autodoc_typehints.process_signature)
|
app.connect('autodoc-process-signature', sphinx_autodoc_typehints.process_signature)
|
||||||
app.connect('autodoc-process-docstring', sphinx_autodoc_typehints.process_docstring)
|
app.connect('autodoc-process-docstring', sphinx_autodoc_typehints.process_docstring)
|
||||||
|
|||||||
+1
-1
@@ -43,7 +43,7 @@ See :ref:`install` for more options on how to install Instaloader.
|
|||||||
|
|
||||||
instaloader [--comments] [--geotags] [--stories] [--highlights] [--tagged]
|
instaloader [--comments] [--geotags] [--stories] [--highlights] [--tagged]
|
||||||
[--login YOUR-USERNAME] [--fast-update]
|
[--login YOUR-USERNAME] [--fast-update]
|
||||||
profile | "#hashtag" |
|
profile | "#hashtag" | %location_id |
|
||||||
:stories | :feed | :saved
|
:stories | :feed | :saved
|
||||||
|
|
||||||
See :ref:`download-pictures-from-instagram` for a detailed introduction on how
|
See :ref:`download-pictures-from-instagram` for a detailed introduction on how
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# sphinx_autodoc_typehints from Mar 18 2018.
|
# sphinx_autodoc_typehints from Nov 20 2018 (version 1.5.2).
|
||||||
# https://github.com/agronholm/sphinx-autodoc-typehints
|
# https://github.com/agronholm/sphinx-autodoc-typehints
|
||||||
# (slightly modified to fix class links, maybe related to agronholm/sphinx-autodoc-typehints#38)
|
# (slightly modified to fix class links, maybe related to agronholm/sphinx-autodoc-typehints#38)
|
||||||
#
|
#
|
||||||
@@ -27,8 +27,8 @@
|
|||||||
import inspect
|
import inspect
|
||||||
from typing import get_type_hints, TypeVar, Any, AnyStr, Generic, Union
|
from typing import get_type_hints, TypeVar, Any, AnyStr, Generic, Union
|
||||||
|
|
||||||
from sphinx.util.inspect import getargspec
|
from sphinx.util import logging
|
||||||
from sphinx.ext.autodoc import formatargspec
|
from sphinx.util.inspect import Signature
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from inspect import unwrap
|
from inspect import unwrap
|
||||||
@@ -51,6 +51,8 @@ except ImportError:
|
|||||||
memo.add(id_func)
|
memo.add(id_func)
|
||||||
return func
|
return func
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def format_annotation(annotation):
|
def format_annotation(annotation):
|
||||||
if inspect.isclass(annotation) and annotation.__module__ == 'builtins':
|
if inspect.isclass(annotation) and annotation.__module__ == 'builtins':
|
||||||
@@ -162,23 +164,37 @@ def process_signature(app, what: str, name: str, obj, options, signature, return
|
|||||||
return
|
return
|
||||||
|
|
||||||
obj = unwrap(obj)
|
obj = unwrap(obj)
|
||||||
try:
|
signature = Signature(obj)
|
||||||
argspec = getargspec(obj)
|
parameters = [
|
||||||
except (TypeError, ValueError):
|
param.replace(annotation=inspect.Parameter.empty)
|
||||||
return
|
for param in signature.signature.parameters.values()
|
||||||
|
]
|
||||||
|
|
||||||
if argspec.args:
|
if parameters:
|
||||||
if what in ('class', 'exception'):
|
if what in ('class', 'exception'):
|
||||||
del argspec.args[0]
|
del parameters[0]
|
||||||
elif what == 'method':
|
elif what == 'method':
|
||||||
outer = inspect.getmodule(obj)
|
outer = inspect.getmodule(obj)
|
||||||
for clsname in obj.__qualname__.split('.')[:-1]:
|
for clsname in obj.__qualname__.split('.')[:-1]:
|
||||||
outer = getattr(outer, clsname)
|
outer = getattr(outer, clsname)
|
||||||
method_object = outer.__dict__[obj.__name__]
|
|
||||||
if not isinstance(method_object, (classmethod, staticmethod)):
|
|
||||||
del argspec.args[0]
|
|
||||||
|
|
||||||
return formatargspec(obj, *argspec[:-1]), None
|
method_name = obj.__name__
|
||||||
|
if method_name.startswith("__") and not method_name.endswith("__"):
|
||||||
|
# If the method starts with double underscore (dunder)
|
||||||
|
# Python applies mangling so we need to prepend the class name.
|
||||||
|
# This doesn't happen if it always ends with double underscore.
|
||||||
|
class_name = obj.__qualname__.split('.')[-2]
|
||||||
|
method_name = "_{c}{m}".format(c=class_name, m=method_name)
|
||||||
|
|
||||||
|
method_object = outer.__dict__[method_name]
|
||||||
|
if not isinstance(method_object, (classmethod, staticmethod)):
|
||||||
|
del parameters[0]
|
||||||
|
|
||||||
|
signature.signature = signature.signature.replace(
|
||||||
|
parameters=parameters,
|
||||||
|
return_annotation=inspect.Signature.empty)
|
||||||
|
|
||||||
|
return signature.format_args().replace('\\', '\\\\'), None
|
||||||
|
|
||||||
|
|
||||||
def process_docstring(app, what, name, obj, options, lines):
|
def process_docstring(app, what, name, obj, options, lines):
|
||||||
@@ -195,8 +211,15 @@ def process_docstring(app, what, name, obj, options, lines):
|
|||||||
except (AttributeError, TypeError):
|
except (AttributeError, TypeError):
|
||||||
# Introspecting a slot wrapper will raise TypeError
|
# Introspecting a slot wrapper will raise TypeError
|
||||||
return
|
return
|
||||||
|
except NameError as exc:
|
||||||
|
logger.warning('Cannot resolve forward reference in type annotations of "%s": %s',
|
||||||
|
name, exc)
|
||||||
|
type_hints = obj.__annotations__
|
||||||
|
|
||||||
for argname, annotation in type_hints.items():
|
for argname, annotation in type_hints.items():
|
||||||
|
if argname.endswith('_'):
|
||||||
|
argname = '{}\\_'.format(argname[:-1])
|
||||||
|
|
||||||
formatted_annotation = format_annotation(annotation)
|
formatted_annotation = format_annotation(annotation)
|
||||||
|
|
||||||
if argname == 'return':
|
if argname == 'return':
|
||||||
|
|||||||
@@ -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.1.1'
|
__version__ = '4.2rc2'
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+26
-4
@@ -8,7 +8,8 @@ from argparse import ArgumentParser, SUPPRESS
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from . import (Instaloader, InstaloaderException, InvalidArgumentException, Post, Profile, ProfileNotExistsException,
|
from . import (Instaloader, InstaloaderException, InvalidArgumentException, Post, Profile, ProfileNotExistsException,
|
||||||
StoryItem, __version__, load_structure_from_file)
|
StoryItem, __version__, load_structure_from_file, TwoFactorAuthRequiredException,
|
||||||
|
BadCredentialsException)
|
||||||
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
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ def usage_string():
|
|||||||
return """
|
return """
|
||||||
{0} [--comments] [--geotags] [--stories] [--highlights] [--tagged]
|
{0} [--comments] [--geotags] [--stories] [--highlights] [--tagged]
|
||||||
{2:{1}} [--login YOUR-USERNAME] [--fast-update]
|
{2:{1}} [--login YOUR-USERNAME] [--fast-update]
|
||||||
{2:{1}} profile | "#hashtag" | :stories | :feed | :saved
|
{2:{1}} profile | "#hashtag" | %%location_id | :stories | :feed | :saved
|
||||||
{0} --help""".format(argv0, len(argv0), '')
|
{0} --help""".format(argv0, len(argv0), '')
|
||||||
|
|
||||||
|
|
||||||
@@ -84,7 +85,16 @@ def _main(instaloader: Instaloader, targetlist: List[str],
|
|||||||
instaloader.context.log("Session file does not exist yet - Logging in.")
|
instaloader.context.log("Session file does not exist yet - Logging in.")
|
||||||
if not instaloader.context.is_logged_in or username != instaloader.test_login():
|
if not instaloader.context.is_logged_in or username != instaloader.test_login():
|
||||||
if password is not None:
|
if password is not None:
|
||||||
instaloader.login(username, password)
|
try:
|
||||||
|
instaloader.login(username, password)
|
||||||
|
except TwoFactorAuthRequiredException:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
code = input("Enter 2FA verification code: ")
|
||||||
|
instaloader.two_factor_login(code)
|
||||||
|
break
|
||||||
|
except BadCredentialsException:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
instaloader.interactive_login(username)
|
instaloader.interactive_login(username)
|
||||||
instaloader.context.log("Logged in as %s." % username)
|
instaloader.context.log("Logged in as %s." % username)
|
||||||
@@ -130,6 +140,9 @@ def _main(instaloader: Instaloader, targetlist: List[str],
|
|||||||
post_filter=post_filter)
|
post_filter=post_filter)
|
||||||
elif target[0] == '-':
|
elif target[0] == '-':
|
||||||
instaloader.download_post(Post.from_shortcode(instaloader.context, target[1:]), target)
|
instaloader.download_post(Post.from_shortcode(instaloader.context, target[1:]), target)
|
||||||
|
elif target[0] == "%":
|
||||||
|
instaloader.download_location(location=target[1:], max_count=max_count, fast_update=fast_update,
|
||||||
|
post_filter=post_filter)
|
||||||
elif target == ":feed":
|
elif target == ":feed":
|
||||||
instaloader.download_feed_posts(fast_update=fast_update, max_count=max_count,
|
instaloader.download_feed_posts(fast_update=fast_update, max_count=max_count,
|
||||||
post_filter=post_filter)
|
post_filter=post_filter)
|
||||||
@@ -208,6 +221,7 @@ def main():
|
|||||||
help="Download all followees of profile. Requires --login. "
|
help="Download all followees of profile. Requires --login. "
|
||||||
"Consider using :feed rather than @yourself.")
|
"Consider using :feed rather than @yourself.")
|
||||||
g_targets.add_argument('_hashtag', nargs='*', metavar='"#hashtag"', help="Download #hashtag.")
|
g_targets.add_argument('_hashtag', nargs='*', metavar='"#hashtag"', help="Download #hashtag.")
|
||||||
|
g_targets.add_argument('_location', nargs='*', metavar='%location_id', help="Download %%location_id.")
|
||||||
g_targets.add_argument('_feed', nargs='*', metavar=":feed",
|
g_targets.add_argument('_feed', nargs='*', metavar=":feed",
|
||||||
help="Download pictures from your feed. Requires --login.")
|
help="Download pictures from your feed. Requires --login.")
|
||||||
g_targets.add_argument('_stories', nargs='*', metavar=":stories",
|
g_targets.add_argument('_stories', nargs='*', metavar=":stories",
|
||||||
@@ -320,6 +334,10 @@ def main():
|
|||||||
help='Maximum number of connection attempts until a request is aborted. Defaults to 3. If a '
|
help='Maximum number of connection attempts until a request is aborted. Defaults to 3. If a '
|
||||||
'connection fails, it can be manually skipped by hitting CTRL+C. Set this to 0 to retry '
|
'connection fails, it can be manually skipped by hitting CTRL+C. Set this to 0 to retry '
|
||||||
'infinitely.')
|
'infinitely.')
|
||||||
|
g_how.add_argument('--commit-mode', action='store_true',
|
||||||
|
help='Tries to ensure downloaded images avoid corruption in case of unexpected interruption. '
|
||||||
|
'If the last picture is corrupted, Instaloader will fix the picture the next time it is run. '
|
||||||
|
'Requires the JSON metadata to be saved.')
|
||||||
|
|
||||||
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',
|
||||||
@@ -361,6 +379,9 @@ def main():
|
|||||||
download_posts = not (args.no_posts or args.stories_only or args.profile_pic_only)
|
download_posts = not (args.no_posts or args.stories_only or args.profile_pic_only)
|
||||||
download_stories = args.stories or args.stories_only
|
download_stories = args.stories or args.stories_only
|
||||||
|
|
||||||
|
if args.commit_mode and args.no_metadata_json:
|
||||||
|
raise SystemExit('--commit-mode requires JSON metadata to be saved.')
|
||||||
|
|
||||||
loader = Instaloader(sleep=not args.no_sleep, quiet=args.quiet, user_agent=args.user_agent,
|
loader = Instaloader(sleep=not args.no_sleep, quiet=args.quiet, user_agent=args.user_agent,
|
||||||
dirname_pattern=args.dirname_pattern, filename_pattern=args.filename_pattern,
|
dirname_pattern=args.dirname_pattern, filename_pattern=args.filename_pattern,
|
||||||
download_pictures=not args.no_pictures,
|
download_pictures=not args.no_pictures,
|
||||||
@@ -371,7 +392,8 @@ def main():
|
|||||||
post_metadata_txt_pattern=post_metadata_txt_pattern,
|
post_metadata_txt_pattern=post_metadata_txt_pattern,
|
||||||
storyitem_metadata_txt_pattern=storyitem_metadata_txt_pattern,
|
storyitem_metadata_txt_pattern=storyitem_metadata_txt_pattern,
|
||||||
graphql_rate_limit=args.graphql_rate_limit,
|
graphql_rate_limit=args.graphql_rate_limit,
|
||||||
max_connection_attempts=args.max_connection_attempts)
|
max_connection_attempts=args.max_connection_attempts,
|
||||||
|
commit_mode=args.commit_mode)
|
||||||
_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,
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ class LoginRequiredException(InstaloaderException):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorAuthRequiredException(InstaloaderException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class InvalidArgumentException(InstaloaderException):
|
class InvalidArgumentException(InstaloaderException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
+122
-14
@@ -1,5 +1,6 @@
|
|||||||
import getpass
|
import getpass
|
||||||
import json
|
import json
|
||||||
|
import lzma
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
@@ -15,7 +16,7 @@ from typing import Any, Callable, Iterator, List, Optional, Set, Union
|
|||||||
|
|
||||||
from .exceptions import *
|
from .exceptions import *
|
||||||
from .instaloadercontext import InstaloaderContext
|
from .instaloadercontext import InstaloaderContext
|
||||||
from .structures import Highlight, JsonExportable, Post, PostLocation, Profile, Story, StoryItem, save_structure_to_file
|
from .structures import Highlight, JsonExportable, Post, PostLocation, Profile, Story, StoryItem, save_structure_to_file, load_structure_from_file
|
||||||
|
|
||||||
|
|
||||||
def get_default_session_filename(username: str) -> str:
|
def get_default_session_filename(username: str) -> str:
|
||||||
@@ -95,6 +96,7 @@ class Instaloader:
|
|||||||
txt file.
|
txt file.
|
||||||
:param storyitem_metadata_txt_pattern: :option:`--storyitem-metadata-txt`, default is empty (=none)
|
:param storyitem_metadata_txt_pattern: :option:`--storyitem-metadata-txt`, default is empty (=none)
|
||||||
:param max_connection_attempts: :option:`--max-connection-attempts`
|
:param max_connection_attempts: :option:`--max-connection-attempts`
|
||||||
|
:param commit_mode: :option:`--commit-mode`
|
||||||
|
|
||||||
.. attribute:: context
|
.. attribute:: context
|
||||||
|
|
||||||
@@ -116,7 +118,8 @@ class Instaloader:
|
|||||||
post_metadata_txt_pattern: str = None,
|
post_metadata_txt_pattern: str = None,
|
||||||
storyitem_metadata_txt_pattern: str = None,
|
storyitem_metadata_txt_pattern: str = None,
|
||||||
graphql_rate_limit: Optional[int] = None,
|
graphql_rate_limit: Optional[int] = None,
|
||||||
max_connection_attempts: int = 3):
|
max_connection_attempts: int = 3,
|
||||||
|
commit_mode: bool = False):
|
||||||
|
|
||||||
self.context = InstaloaderContext(sleep, quiet, user_agent, graphql_rate_limit, max_connection_attempts)
|
self.context = InstaloaderContext(sleep, quiet, user_agent, graphql_rate_limit, max_connection_attempts)
|
||||||
|
|
||||||
@@ -134,6 +137,12 @@ class Instaloader:
|
|||||||
else post_metadata_txt_pattern
|
else post_metadata_txt_pattern
|
||||||
self.storyitem_metadata_txt_pattern = '' if storyitem_metadata_txt_pattern is None \
|
self.storyitem_metadata_txt_pattern = '' if storyitem_metadata_txt_pattern is None \
|
||||||
else storyitem_metadata_txt_pattern
|
else storyitem_metadata_txt_pattern
|
||||||
|
self.commit_mode = commit_mode
|
||||||
|
if self.commit_mode and not self.save_metadata:
|
||||||
|
raise InvalidArgumentException("Commit mode requires JSON metadata to be saved.")
|
||||||
|
|
||||||
|
# Used to keep state in commit mode
|
||||||
|
self._committed = None
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def anonymous_copy(self):
|
def anonymous_copy(self):
|
||||||
@@ -173,9 +182,15 @@ class Instaloader:
|
|||||||
if filename_suffix is not None:
|
if filename_suffix is not None:
|
||||||
filename += '_' + filename_suffix
|
filename += '_' + filename_suffix
|
||||||
filename += '.' + file_extension
|
filename += '.' + file_extension
|
||||||
if os.path.isfile(filename):
|
# A post is considered "commited" if the json file exists and is not malformed.
|
||||||
self.context.log(filename + ' exists', end=' ', flush=True)
|
if self.commit_mode:
|
||||||
return False
|
if self._committed and os.path.isfile(filename):
|
||||||
|
self.context.log(filename + ' exists', end=' ', flush=True)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if os.path.isfile(filename):
|
||||||
|
self.context.log(filename + ' exists', end=' ', flush=True)
|
||||||
|
return False
|
||||||
self.context.get_and_write_raw(url, filename)
|
self.context.get_and_write_raw(url, filename)
|
||||||
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
|
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
|
||||||
return True
|
return True
|
||||||
@@ -335,9 +350,20 @@ class Instaloader:
|
|||||||
|
|
||||||
:raises InvalidArgumentException: If the provided username does not exist.
|
:raises InvalidArgumentException: If the provided username does not exist.
|
||||||
:raises BadCredentialsException: If the provided password is wrong.
|
:raises BadCredentialsException: If the provided password is wrong.
|
||||||
:raises ConnectionException: If connection to Instagram failed."""
|
:raises ConnectionException: If connection to Instagram failed.
|
||||||
|
:raises TwoFactorAuthRequiredException: First step of 2FA login done, now call :meth:`Instaloader.two_factor_login`."""
|
||||||
self.context.login(user, passwd)
|
self.context.login(user, passwd)
|
||||||
|
|
||||||
|
def two_factor_login(self, two_factor_code) -> None:
|
||||||
|
"""Second step of login if 2FA is enabled.
|
||||||
|
Not meant to be used directly, use :meth:`Instaloader.two_factor_login`.
|
||||||
|
|
||||||
|
:raises InvalidArgumentException: No two-factor authentication pending.
|
||||||
|
:raises BadCredentialsException: 2FA verification code invalid.
|
||||||
|
|
||||||
|
.. versionadded:: 4.2"""
|
||||||
|
self.context.two_factor_login(two_factor_code)
|
||||||
|
|
||||||
def format_filename(self, item: Union[Post, StoryItem], target: Optional[str] = None):
|
def format_filename(self, item: Union[Post, StoryItem], target: Optional[str] = None):
|
||||||
"""Format filename of a :class:`Post` or :class:`StoryItem` according to ``filename-pattern`` parameter.
|
"""Format filename of a :class:`Post` or :class:`StoryItem` according to ``filename-pattern`` parameter.
|
||||||
|
|
||||||
@@ -359,6 +385,7 @@ class Instaloader:
|
|||||||
|
|
||||||
# Download the image(s) / video thumbnail and videos within sidecars if desired
|
# Download the image(s) / video thumbnail and videos within sidecars if desired
|
||||||
downloaded = True
|
downloaded = True
|
||||||
|
self._committed = self.check_if_committed(filename)
|
||||||
if self.download_pictures:
|
if self.download_pictures:
|
||||||
if post.typename == 'GraphSidecar':
|
if post.typename == 'GraphSidecar':
|
||||||
edge_number = 1
|
edge_number = 1
|
||||||
@@ -632,6 +659,59 @@ class Instaloader:
|
|||||||
if fast_update and not downloaded:
|
if fast_update and not downloaded:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
def get_location_posts(self, location: str) -> Iterator[Post]:
|
||||||
|
"""Get Posts which are listed by Instagram for a given Location.
|
||||||
|
|
||||||
|
:return: Iterator over Posts of a location's posts
|
||||||
|
|
||||||
|
.. versionadded:: 4.2
|
||||||
|
"""
|
||||||
|
has_next_page = True
|
||||||
|
end_cursor = None
|
||||||
|
while has_next_page:
|
||||||
|
if end_cursor:
|
||||||
|
params = {'__a': 1, 'max_id': end_cursor}
|
||||||
|
else:
|
||||||
|
params = {'__a': 1}
|
||||||
|
location_data = self.context.get_json('explore/locations/{0}/'.format(location),
|
||||||
|
params)['graphql']['location']['edge_location_to_media']
|
||||||
|
yield from (Post(self.context, edge['node']) for edge in location_data['edges'])
|
||||||
|
has_next_page = location_data['page_info']['has_next_page']
|
||||||
|
end_cursor = location_data['page_info']['end_cursor']
|
||||||
|
|
||||||
|
def download_location(self, location: str,
|
||||||
|
max_count: Optional[int] = None,
|
||||||
|
post_filter: Optional[Callable[[Post], bool]] = None,
|
||||||
|
fast_update: bool = False) -> None:
|
||||||
|
"""Download pictures of one location.
|
||||||
|
|
||||||
|
To download the last 30 pictures with location 362629379, do::
|
||||||
|
|
||||||
|
loader = Instaloader()
|
||||||
|
loader.download_location(362629379, max_count=30)
|
||||||
|
|
||||||
|
:param location: Location to download, as Instagram numerical ID
|
||||||
|
:param max_count: Maximum count of pictures to download
|
||||||
|
:param post_filter: function(post), which returns True if given picture should be downloaded
|
||||||
|
:param fast_update: If true, abort when first already-downloaded picture is encountered
|
||||||
|
|
||||||
|
.. versionadded:: 4.2
|
||||||
|
"""
|
||||||
|
self.context.log("Retrieving pictures for location {}...".format(location))
|
||||||
|
count = 1
|
||||||
|
for post in self.get_location_posts(location):
|
||||||
|
if max_count is not None and count > max_count:
|
||||||
|
break
|
||||||
|
self.context.log('[{0:3d}] %{1} '.format(count, location), end='', flush=True)
|
||||||
|
if post_filter is not None and not post_filter(post):
|
||||||
|
self.context.log('<skipped>')
|
||||||
|
continue
|
||||||
|
count += 1
|
||||||
|
with self.context.error_catcher('Download location {}'.format(location)):
|
||||||
|
downloaded = self.download_post(post, target='%' + location)
|
||||||
|
if fast_update and not downloaded:
|
||||||
|
break
|
||||||
|
|
||||||
@_requires_login
|
@_requires_login
|
||||||
def get_explore_posts(self) -> Iterator[Post]:
|
def get_explore_posts(self) -> Iterator[Post]:
|
||||||
"""Get Posts which are worthy of exploring suggested by Instagram.
|
"""Get Posts which are worthy of exploring suggested by Instagram.
|
||||||
@@ -945,6 +1025,25 @@ class Instaloader:
|
|||||||
if fast_update and not downloaded:
|
if fast_update and not downloaded:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
def check_if_committed(self, filename: str) -> bool:
|
||||||
|
"""Checks to see if the current post has been committed.
|
||||||
|
|
||||||
|
A post is considered committed if its json metadata file exists and is not malformed.
|
||||||
|
|
||||||
|
.. versionadded:: 4.2
|
||||||
|
"""
|
||||||
|
if os.path.isfile(filename + '.json.xz'):
|
||||||
|
filename += '.json.xz'
|
||||||
|
elif os.path.isfile(filename + '.json'):
|
||||||
|
filename += '.json'
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
load_structure_from_file(self.context, filename)
|
||||||
|
return True
|
||||||
|
except (FileNotFoundError, lzma.LZMAError, json.decoder.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
|
||||||
def interactive_login(self, username: str) -> None:
|
def interactive_login(self, username: str) -> None:
|
||||||
"""Logs in and internally stores session, asking user for password interactively.
|
"""Logs in and internally stores session, asking user for password interactively.
|
||||||
|
|
||||||
@@ -953,11 +1052,20 @@ class Instaloader:
|
|||||||
:raises ConnectionException: If connection to Instagram failed."""
|
:raises ConnectionException: If connection to Instagram failed."""
|
||||||
if self.context.quiet:
|
if self.context.quiet:
|
||||||
raise LoginRequiredException("Quiet mode requires given password or valid session file.")
|
raise LoginRequiredException("Quiet mode requires given password or valid session file.")
|
||||||
password = None
|
try:
|
||||||
while password is None:
|
password = None
|
||||||
password = getpass.getpass(prompt="Enter Instagram password for %s: " % username)
|
while password is None:
|
||||||
try:
|
password = getpass.getpass(prompt="Enter Instagram password for %s: " % username)
|
||||||
self.login(username, password)
|
try:
|
||||||
except BadCredentialsException as err:
|
self.login(username, password)
|
||||||
print(err, file=sys.stderr)
|
except BadCredentialsException as err:
|
||||||
password = None
|
print(err, file=sys.stderr)
|
||||||
|
password = None
|
||||||
|
except TwoFactorAuthRequiredException:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
code = input("Enter 2FA verification code: ")
|
||||||
|
self.two_factor_login(code)
|
||||||
|
break
|
||||||
|
except BadCredentialsException:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class InstaloaderContext:
|
|||||||
self._graphql_page_length = 50
|
self._graphql_page_length = 50
|
||||||
self.graphql_count_per_slidingwindow = graphql_count_per_slidingwindow or 200
|
self.graphql_count_per_slidingwindow = graphql_count_per_slidingwindow or 200
|
||||||
self._root_rhx_gis = None
|
self._root_rhx_gis = None
|
||||||
|
self.two_factor_auth_pending = None
|
||||||
|
|
||||||
# error log, filled with error() and printed at the end of Instaloader.main()
|
# error log, filled with error() and printed at the end of Instaloader.main()
|
||||||
self.error_log = []
|
self.error_log = []
|
||||||
@@ -180,23 +181,32 @@ class InstaloaderContext:
|
|||||||
|
|
||||||
:raises InvalidArgumentException: If the provided username does not exist.
|
:raises InvalidArgumentException: If the provided username does not exist.
|
||||||
:raises BadCredentialsException: If the provided password is wrong.
|
:raises BadCredentialsException: If the provided password is wrong.
|
||||||
:raises ConnectionException: If connection to Instagram failed."""
|
:raises ConnectionException: If connection to Instagram failed.
|
||||||
|
:raises TwoFactorAuthRequiredException: First step of 2FA login done, now call :meth:`Instaloader.two_factor_login`."""
|
||||||
import http.client
|
import http.client
|
||||||
# pylint:disable=protected-access
|
# pylint:disable=protected-access
|
||||||
http.client._MAXHEADERS = 200
|
http.client._MAXHEADERS = 200
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
session.cookies.update({'sessionid': '', 'mid': '', 'ig_pr': '1',
|
session.cookies.update({'sessionid': '', 'mid': '', 'ig_pr': '1',
|
||||||
'ig_vw': '1920', 'csrftoken': '',
|
'ig_vw': '1920', 'ig_cb': '1', 'csrftoken': '',
|
||||||
's_network': '', 'ds_user_id': ''})
|
's_network': '', 'ds_user_id': ''})
|
||||||
session.headers.update(self._default_http_header())
|
session.headers.update(self._default_http_header())
|
||||||
session.headers.update({'X-CSRFToken': self.get_json('', {})['config']['csrf_token']})
|
session.get('https://www.instagram.com/web/__mid/')
|
||||||
|
csrf_token = session.cookies.get_dict()['csrftoken']
|
||||||
|
session.headers.update({'X-CSRFToken': csrf_token})
|
||||||
# Not using self.get_json() here, because we need to access csrftoken cookie
|
# Not using self.get_json() here, because we need to access csrftoken cookie
|
||||||
self._sleep()
|
self._sleep()
|
||||||
login = session.post('https://www.instagram.com/accounts/login/ajax/',
|
login = session.post('https://www.instagram.com/accounts/login/ajax/',
|
||||||
data={'password': passwd, 'username': user}, allow_redirects=True)
|
data={'password': passwd, 'username': user}, allow_redirects=True)
|
||||||
if login.status_code != 200:
|
|
||||||
raise ConnectionException("Login error: {} {}".format(login.status_code, login.reason))
|
|
||||||
resp_json = login.json()
|
resp_json = login.json()
|
||||||
|
if resp_json.get('two_factor_required'):
|
||||||
|
two_factor_session = copy_session(session)
|
||||||
|
two_factor_session.headers.update({'X-CSRFToken': csrf_token})
|
||||||
|
two_factor_session.cookies.update({'csrftoken': csrf_token})
|
||||||
|
self.two_factor_auth_pending = (two_factor_session,
|
||||||
|
user,
|
||||||
|
resp_json['two_factor_info']['two_factor_identifier'])
|
||||||
|
raise TwoFactorAuthRequiredException("Login error: two-factor authentication required.")
|
||||||
if resp_json['status'] != 'ok':
|
if resp_json['status'] != 'ok':
|
||||||
if 'message' in resp_json:
|
if 'message' in resp_json:
|
||||||
raise ConnectionException("Login error: \"{}\" status, message \"{}\".".format(resp_json['status'],
|
raise ConnectionException("Login error: \"{}\" status, message \"{}\".".format(resp_json['status'],
|
||||||
@@ -218,6 +228,32 @@ class InstaloaderContext:
|
|||||||
self._session = session
|
self._session = session
|
||||||
self.username = user
|
self.username = user
|
||||||
|
|
||||||
|
def two_factor_login(self, two_factor_code):
|
||||||
|
"""Second step of login if 2FA is enabled.
|
||||||
|
Not meant to be used directly, use :meth:`Instaloader.two_factor_login`.
|
||||||
|
|
||||||
|
:raises InvalidArgumentException: No two-factor authentication pending.
|
||||||
|
:raises BadCredentialsException: 2FA verification code invalid.
|
||||||
|
|
||||||
|
.. versionadded:: 4.2"""
|
||||||
|
if not self.two_factor_auth_pending:
|
||||||
|
raise InvalidArgumentException("No two-factor authentication pending.")
|
||||||
|
(session, user, two_factor_id) = self.two_factor_auth_pending
|
||||||
|
|
||||||
|
login = session.post('https://www.instagram.com/accounts/login/ajax/two_factor/',
|
||||||
|
data={'username': user, 'verificationCode': two_factor_code, 'identifier': two_factor_id},
|
||||||
|
allow_redirects=True)
|
||||||
|
resp_json = login.json()
|
||||||
|
if resp_json['status'] != 'ok':
|
||||||
|
if 'message' in resp_json:
|
||||||
|
raise BadCredentialsException("Login error: {}".format(resp_json['message']))
|
||||||
|
else:
|
||||||
|
raise BadCredentialsException("Login error: \"{}\" status.".format(resp_json['status']))
|
||||||
|
session.headers.update({'X-CSRFToken': login.cookies['csrftoken']})
|
||||||
|
self._session = session
|
||||||
|
self.username = user
|
||||||
|
self.two_factor_auth_pending = None
|
||||||
|
|
||||||
def _sleep(self):
|
def _sleep(self):
|
||||||
"""Sleep a short time if self.sleep is set. Called before each request to instagram.com."""
|
"""Sleep a short time if self.sleep is set. Called before each request to instagram.com."""
|
||||||
if self.sleep:
|
if self.sleep:
|
||||||
|
|||||||
@@ -373,10 +373,6 @@ class Profile:
|
|||||||
self._has_public_story = None
|
self._has_public_story = None
|
||||||
self._node = node
|
self._node = node
|
||||||
self._rhx_gis = None
|
self._rhx_gis = None
|
||||||
self._iphone_struct_ = None
|
|
||||||
if 'iphone_struct' in node:
|
|
||||||
# if loaded from JSON with load_structure_from_file()
|
|
||||||
self._iphone_struct_ = node['iphone_struct']
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_username(cls, context: InstaloaderContext, username: str):
|
def from_username(cls, context: InstaloaderContext, username: str):
|
||||||
@@ -427,8 +423,6 @@ class Profile:
|
|||||||
json_node.pop('edge_media_collections', None)
|
json_node.pop('edge_media_collections', None)
|
||||||
json_node.pop('edge_owner_to_timeline_media', None)
|
json_node.pop('edge_owner_to_timeline_media', None)
|
||||||
json_node.pop('edge_saved_media', None)
|
json_node.pop('edge_saved_media', None)
|
||||||
if self._iphone_struct_:
|
|
||||||
json_node['iphone_struct'] = self._iphone_struct_
|
|
||||||
return json_node
|
return json_node
|
||||||
|
|
||||||
def _obtain_metadata(self):
|
def _obtain_metadata(self):
|
||||||
@@ -453,15 +447,6 @@ class Profile:
|
|||||||
d = d[key]
|
d = d[key]
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@property
|
|
||||||
def _iphone_struct(self) -> Dict[str, Any]:
|
|
||||||
if not self._iphone_struct_:
|
|
||||||
with self._context.anonymous_copy() as anonymous_context:
|
|
||||||
data = anonymous_context.get_json(path='api/v1/users/{}/info/'.format(self.userid),
|
|
||||||
params={}, host='i.instagram.com')
|
|
||||||
self._iphone_struct_ = data['user']
|
|
||||||
return self._iphone_struct_
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def userid(self) -> int:
|
def userid(self) -> int:
|
||||||
"""User ID"""
|
"""User ID"""
|
||||||
@@ -581,11 +566,7 @@ class Profile:
|
|||||||
"""Return URL of profile picture
|
"""Return URL of profile picture
|
||||||
|
|
||||||
.. versionadded:: 4.0.3"""
|
.. versionadded:: 4.0.3"""
|
||||||
try:
|
return self._metadata("profile_pic_url_hd")
|
||||||
return self._iphone_struct['hd_profile_pic_url_info']['url']
|
|
||||||
except (InstaloaderException, KeyError) as err:
|
|
||||||
self._context.error('{} Unable to fetch high quality profile pic.'.format(err))
|
|
||||||
return self._metadata("profile_pic_url_hd")
|
|
||||||
|
|
||||||
def get_profile_pic_url(self) -> str:
|
def get_profile_pic_url(self) -> str:
|
||||||
""".. deprecated:: 4.0.3
|
""".. deprecated:: 4.0.3
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ PROFILE_WITH_HIGHLIGHTS = 325732271
|
|||||||
PUBLIC_PROFILE = "selenagomez"
|
PUBLIC_PROFILE = "selenagomez"
|
||||||
PUBLIC_PROFILE_ID = 460563723
|
PUBLIC_PROFILE_ID = 460563723
|
||||||
HASHTAG = "kitten"
|
HASHTAG = "kitten"
|
||||||
|
LOCATION = "362629379"
|
||||||
OWN_USERNAME = "aandergr"
|
OWN_USERNAME = "aandergr"
|
||||||
NORMAL_MAX_COUNT = 2
|
NORMAL_MAX_COUNT = 2
|
||||||
PAGING_MAX_COUNT = 15
|
PAGING_MAX_COUNT = 15
|
||||||
@@ -69,6 +70,15 @@ class TestInstaloaderAnonymously(unittest.TestCase):
|
|||||||
if count == PAGING_MAX_COUNT:
|
if count == PAGING_MAX_COUNT:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
def test_location_download(self):
|
||||||
|
self.L.download_location(LOCATION, NORMAL_MAX_COUNT)
|
||||||
|
|
||||||
|
def test_location_paging(self):
|
||||||
|
for count, post in enumerate(self.L.get_location_posts(LOCATION)):
|
||||||
|
print(post)
|
||||||
|
if count == PAGING_MAX_COUNT:
|
||||||
|
break
|
||||||
|
|
||||||
def test_get_id_by_username(self):
|
def test_get_id_by_username(self):
|
||||||
self.assertEqual(PUBLIC_PROFILE_ID,
|
self.assertEqual(PUBLIC_PROFILE_ID,
|
||||||
instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).userid)
|
instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).userid)
|
||||||
|
|||||||
Reference in New Issue
Block a user