Compare commits

...
32 Commits
Author SHA1 Message Date
André Koch-Kramer 52492456ed Release of version 1.3 2017-04-22 18:01:54 +02:00
Alexander Graf e0924e8d08 Fix --geotags 2017-04-22 17:54:21 +02:00
Alexander Graf 655dbb552d Further clarify meaning of --count 2017-04-22 17:34:49 +02:00
André Koch-Kramer fdb8e94c64 Fixed ":feed-liked" functionality 2017-04-22 17:26:48 +02:00
Alexander Graf b3c83f420c --count to limit posts at #hashtag and :feed-* 2017-04-22 17:21:02 +02:00
Alexander Graf df00e2b03a Minor enhancements to README 2017-04-22 12:11:56 +02:00
Alexander Graf b60f0b4836 README: Document all of Instaloader's features 2017-04-22 11:58:59 +02:00
Alexander Graf 35e71192c5 travis: Enable more Pylint inspections 2017-04-22 11:58:12 +02:00
André Koch-Kramer 2106c2d5f6 Satisfy pylint after their update 2017-04-22 11:13:10 +02:00
André Koch-Kramer 0e943189e5 Parse graphql structure for sidecars 2017-04-22 10:50:12 +02:00
André Koch-Kramer 8e77a1c125 Fixed download_node() 2017-04-22 01:39:52 +02:00
André Koch-Kramer 361445519a Adapt new graphql layout used by Instagram
Fixes #19.
2017-04-21 18:10:41 +02:00
Alexander Graf 6b345e1f52 Adapt video downloading to new format
This should the video downloading issue reported at #18.
2017-04-20 09:17:59 +02:00
Alexander Graf c841bca81b Silence new pylint warnings 2017-04-20 09:17:32 +02:00
Alexander Graf 3e1360160d Download pictures with #hashtag
Instaloader is now capable of downloading all pictures associated with
one #hashtag with:
instaloader #hashtag

This implements the feature requested with #18.
2017-04-17 12:16:22 +02:00
Alexander Graf a7d1c5bbb0 Make check_id() exceptions Non-fatal
The check_id() step, including the get_username_by_id(), which is used
to determine whether a profile's account name has changed since the last
download using it's unique id when Instaloader is operating logged-in,
is actually optional and should not cause termination in any case.
2017-04-10 21:05:58 +02:00
Alexander Graf dc748a0541 Download all pictures of Sidecar nodes 2017-03-25 21:08:54 +01:00
Alexander Graf 72c647829a Don't fail if --sessionfile does not contain '/' 2017-03-21 14:58:13 +01:00
Alexander Graf 0a039b9c8c Release of version 1.2.2 2017-03-19 14:52:45 +01:00
Alexander Graf b1014bfbfd README: Mention how to install from our Git repo 2017-03-19 14:42:02 +01:00
Alexander Graf d246268630 Retry download anonymously if profile not exists
In case you are blocked by a public profile which you intend to
download, the server responds as if the profile would not exist. Now in
this case, we retry the download without using an anonymous session.
2017-03-19 12:52:07 +01:00
Alexander Graf 23a0e32e8e Clarify --login is required for download followees 2017-03-19 12:51:20 +01:00
Alexander Graf 597e5977b2 Release of version 1.2.1 2017-02-13 10:25:54 +01:00
Alexander Graf 00f6f47fa9 fix get_id_by_username() 2017-02-13 10:20:45 +01:00
Alexander Graf 61711f3b6d Fix Typos in README.rst 2017-02-13 10:03:34 +01:00
Alexander Graf be477e8a88 Fix very minor packaging issues
- State in README.rst and setup.py metainfo that we require Python>=3.5

- Let Travis-CI test against newer versions of Python

- Let instaloader --help show where to report issues
2017-02-13 09:57:03 +01:00
Alexander Graf 02509d3c40 Fix downloading (set max_id only if not zero)
This should fix #17.
2017-02-13 09:50:20 +01:00
Alexander Graf 6e3c13b5d3 Fix typo in README.rst 2017-01-06 10:33:38 +01:00
Alexander Graf 84c2a823c4 fix typing 2016-12-22 16:05:25 +01:00
Alexander Graf f2660f7c02 Require Python >= 3.5 2016-12-22 13:27:08 +01:00
Alexander Graf 73f85b3316 Adjust travis yml 2016-12-22 13:24:50 +01:00
Alexander Graf 86f8b2f018 Annotate all types 2016-12-22 13:20:41 +01:00
4 changed files with 291 additions and 116 deletions
+4 -3
View File
@@ -1,11 +1,12 @@
language: python
python:
- "3.3"
- "3.4"
- "3.5"
- "3.5-dev"
- "3.6"
- "3.6-dev"
- "3.7-dev"
- "nightly"
install:
- pip install pylint requests
script:
- python3 -m pylint -r n -d bad-whitespace,bad-continuation,missing-docstring,multiple-imports,too-many-arguments,locally-disabled instaloader
- python3 -m pylint -r n -d bad-whitespace,bad-continuation,missing-docstring,multiple-imports,too-many-arguments,locally-disabled,line-too-long instaloader
+68 -21
View File
@@ -1,43 +1,44 @@
Instaloader
===========
Tool to automatically download pictures (or videos) of given profiles
from Instagram.
Installation
------------
instaloader is written in Python, thus ensure having
`Python <https://www.python.org/>`__ (at least version 3.3) installed.
Instaloader is written in Python, thus ensure having
`Python <https://www.python.org/>`__ (at least version 3.5) installed.
If you intend to use this tool under Windows, it is recommended
to install
`win-unicode-console <https://pypi.python.org/pypi/win_unicode_console>`__.
If you have `pip <https://pypi.python.org/pypi/pip>`__ installed, you
may install instaloader using
may install Instaloader using
::
pip3 install instaloader
Alternatively, you may `download instaloader from
github <https://github.com/Thammus/instaloader/releases>`__, unzip it
and invoke bundled ``setup.py`` (requiring
`setuptools <https://pypi.python.org/pypi/setuptools>`__) to install it:
Alternatively, to get the most current version of Instaloader from our
`Git repository <https://github.com/Thammus/instaloader>`__:
::
python3 setup.py install [--user]
pip3 install git+https://github.com/Thammus/instaloader
(pass ``--user`` to install it for your user only instead of globally)
(pass ``--upgrade`` to upgrade if Instaloader is already installed)
instaloader requires
Instaloader requires
`requests <https://pypi.python.org/pypi/requests>`__, which
will be installed automatically by setup.py, if not already installed.
will be installed automatically, if it is not already installed.
How to automatically download pictures from Instagram
-----------------------------------------------------
To download a set of profiles, do
To **download all pictures and videos of a profile**, as well as the
**profile picture**, do
::
@@ -46,13 +47,13 @@ To download a set of profiles, do
where ``profile`` is the name of a profile you want to download. Instead
of only one profile, you may also specify a list of profiles.
To later update your local copy of that profile, you may run
To later **update your local copy** of that profiles, you may run
::
instaloader --fast-update profile [profile ...]
When ``--fast-update`` is given, instaloder terminates when arriving at
When ``--fast-update`` is given, Instaloader stops when arriving at
the first already-downloaded picture.
Instaloader can also be used to **download private profiles**. To do so,
@@ -63,10 +64,17 @@ invoke it with
instaloader --login=your_username profile [profile ...]
When invoked like this, it also **stores the session cookies** in a file
in ``/tmp``, which will be reused later when ``--login`` is given. So
in your temporary directory, which will be reused later when ``--login`` is given. So
you can download private profiles **non-interactively** when you already
have a valid session cookie file.
You may also download
**the most recent pictures by hashtag**:
::
instaloader "#hashtag"
If you want to **download all followees of a given profile**, call
::
@@ -79,17 +87,49 @@ To **download all the pictures from your feed which you have liked**, call
instaloader --login=your_username :feed-liked
The ``--quiet`` option makes it also **suitable as a cron job**.
or to **download all pictures from your feed**:
To get a list of other helpful flags, run ``instaloader --help``.
::
Usage as library
instaloader --login=your_username :feed-all
Advanced Options
----------------
You may also use parts of instaloader as library to do other interesting
The following flags can be given to Instaloader to specify how profiles should
be downloaded.
--fast-update Stop when encountering the first already-downloaded post
of a profile.
--profile-pic-only Only download profile pictures. Without this flag, the current
profile picture and all the profile's posts are downloaded.
--skip-videos Skip posts which are videos.
--geotags Also **download geotags** and store Google Maps links in
separate textfiles.
--count COUNT If used with ``#hashtag``, ``:feed-all`` or
``:feed-liked``: Do not attempt to download more than COUNT
posts.
--quiet Do not output any messages except warnings and errors. This
option makes Instaloader **suitable as a cron job**.
--no-sleep Normally, Instaloader waits a few seconds between requests
to the Instagram servers. This flag inhibits this behavior.
--password PASSWORD If used with ``--login``, use parameter as password if no
valid session file is found, instead of asking
interactively.
--sessionfile FILE Specify an alternative place for loading and storing the
session cookies. Without this flag, they are stored in a path
within your temporary directory, encoding your local
username and your instagram profile name.
To get a list of all flags, run ``instaloader --help``.
Usage as Python module
----------------------
You may also use parts of Instaloader as library to do other interesting
things.
For example, to get a list of all followers of a profile as well as
For example, to get a list of all followees of a profile as well as
their follower count, do
.. code:: python
@@ -119,7 +159,14 @@ You could also download your last 20 liked pics with
.. code:: python
instaloader.download_feed_pics(session, max_count=20, fast_update=True,
filter_func=lambda node: not node["likes"]["viewer_has_liked"])
filter_func=lambda node:
not node["likes"]["viewer_has_liked"] if "likes" in node else not node["viewer_has_liked"])
To download the last 20 pictures with hashtag #cat, do
.. code:: python
instaloader.download_hashtag('cat', session=instaloader.get_anonymous_session(), max_count=20)
Each Instagram profile has its own unique ID which stays unmodified even
if a user changes his/her username. To get said ID, given the profile's
+215 -86
View File
@@ -6,6 +6,9 @@ of profiles (even if private), from your feed or from all followees of a given p
import re, json, datetime, shutil, os, time, random, sys, pickle, getpass, tempfile
from argparse import ArgumentParser
from io import BytesIO
from numbers import Real
from typing import List, Optional, Any, Dict, Callable
import requests, requests.utils
# To get version from setup.py for instaloader --version
@@ -37,6 +40,9 @@ class NonfatalException(InstaloaderException):
class ProfileNotExistsException(NonfatalException):
pass
class ProfileAccessDeniedException(NonfatalException):
pass
class ProfileHasNoPicsException(NonfatalException):
pass
@@ -58,24 +64,25 @@ def _log(*msg, sep='', end='\n', flush=False, quiet=False):
print(*msg, sep=sep, end=end, flush=flush)
def get_json(name, session, max_id=0, sleep=True):
def get_json(name: str, session: requests.Session,
max_id: Optional[str] = None, sleep: bool = True) -> Optional[Dict[str, Any]]:
"""Return JSON of a profile"""
resp = session.get('http://www.instagram.com/'+name,
params={'max_id': max_id})
if not max_id:
resp = session.get('https://www.instagram.com/'+name)
else:
resp = session.get('https://www.instagram.com/'+name, params={'max_id': max_id})
if sleep:
time.sleep(4 * random.random() + 1)
match = re.search('window\\._sharedData = .*<', resp.text)
if match is None:
return None
else:
if match is not None:
return json.loads(match.group(0)[21:-2])
def get_username_by_id(session, profile_id):
def get_username_by_id(session: requests.Session, profile_id: int) -> str:
"""To get the current username of a profile, given its unique ID, this function can be used.
session is required to be a logged-in (i.e. non-anonymous) session."""
tempsession = copy_session(session)
tempsession.headers.update({'Content-Type' : 'application/json'})
tempsession.headers.update({'Content-Type' : 'application/x-www-form-urlencoded'})
resp = tempsession.post('https://www.instagram.com/query/', data='q=ig_user(' +
str(profile_id) +')+%7B%0A++username%0A%7D%0A')
if resp.status_code == 200:
@@ -86,26 +93,26 @@ def get_username_by_id(session, profile_id):
"you (id: " + str(profile_id) + ").")
else:
if test_login(session):
raise ConnectionException("Username could not be determined due to connection error" +
" (id: "+ str(profile_id) +").")
raise ProfileAccessDeniedException("Username could not be determined due to error {0} (id: {1})."
.format(str(resp.status_code), str(profile_id)))
raise LoginRequiredException("Login required to determine username (id: " +
str(profile_id) + ").")
def get_id_by_username(profile):
def get_id_by_username(profile: str) -> int:
"""Each Instagram profile has its own unique ID which stays unmodified even if a user changes
his/her username. To get said ID, given the profile's name, you may call this function."""
data = get_json(profile, get_anonymous_session())
if len(data["entry_data"]) == 0 or "ProfilePage" not in data("entry_data"):
if "ProfilePage" not in data["entry_data"]:
raise ProfileNotExistsException("Profile {0} does not exist.".format(profile))
return int(data['entry_data']['ProfilePage'][0]['user']['id'])
def _epoch_to_string(epoch):
def _epoch_to_string(epoch: Real) -> str:
return datetime.datetime.fromtimestamp(epoch).strftime('%Y-%m-%d_%H-%M-%S')
def get_followees(profile, session):
def get_followees(profile: str, session: requests.Session) -> List[Dict[str, Any]]:
"""
Retrieve list of followees of given profile
@@ -138,7 +145,7 @@ def get_followees(profile, session):
"&ref=relationships%3A%3Afollow_list"]
tmpsession.headers.update(default_http_header())
tmpsession.headers.update({'Referer' : 'https://www.instagram.com/'+profile+'/following/'})
tmpsession.headers.update({'Content-Type' : 'application/json'})
tmpsession.headers.update({'Content-Type' : 'application/x-www-form-urlencoded'})
resp = tmpsession.post('https://www.instagram.com/query/', data=query[0]+"first("+query[1])
if resp.status_code == 200:
data = json.loads(resp.text)
@@ -162,14 +169,18 @@ def get_followees(profile, session):
raise LoginRequiredException("Login required to gather followees.")
def download_pic(name, url, date_epoch, outputlabel=None, quiet=False):
def download_pic(name: str, url: str, date_epoch: Real, outputlabel: Optional[str] = None, quiet: bool = False,
filename_suffix: Optional[str] = None) -> bool:
"""Downloads and saves picture with given url under given directory with given timestamp.
Returns true, if file was actually downloaded, i.e. updated."""
if outputlabel is None:
outputlabel = _epoch_to_string(date_epoch)
urlmatch = re.search('\\.[a-z]*\\?', url)
file_extension = url[-3:] if urlmatch is None else urlmatch.group(0)[1:-1]
filename = name.lower() + '/' + _epoch_to_string(date_epoch) + '.' + file_extension
filename = name.lower() + '/' + _epoch_to_string(date_epoch)
if filename_suffix is not None:
filename += '_' + filename_suffix
filename += '.' + file_extension
if os.path.isfile(filename):
_log(outputlabel + ' exists', end=' ', flush=True, quiet=quiet)
return False
@@ -186,7 +197,7 @@ def download_pic(name, url, date_epoch, outputlabel=None, quiet=False):
raise ConnectionException("File \'" + url + "\' could not be downloaded.")
def save_caption(name, date_epoch, caption, shorter_output=False, quiet=False):
def save_caption(name: str, date_epoch: Real, caption: str, shorter_output: bool = False, quiet: bool = False) -> None:
"""Updates picture caption"""
filename = name.lower() + '/' + _epoch_to_string(date_epoch) + '.txt'
pcaption = caption.replace('\n', ' ').strip()
@@ -229,7 +240,7 @@ def save_caption(name, date_epoch, caption, shorter_output=False, quiet=False):
os.utime(filename, (datetime.datetime.now().timestamp(), date_epoch))
def save_location(name, location_json, date_epoch):
def save_location(name: str, location_json: Dict[str, str], date_epoch: Real, quiet: bool = False) -> None:
filename = name.lower() + '/' + _epoch_to_string(date_epoch) + '_location.txt'
location_string = location_json["name"]+"\n" + \
"https://maps.google.com/maps?q={0},{1}&ll={0},{1}\n" \
@@ -238,9 +249,10 @@ def save_location(name, location_json, date_epoch):
with open(filename, 'wb') as text_file:
shutil.copyfileobj(BytesIO(location_string.encode()), text_file)
os.utime(filename, (datetime.datetime.now().timestamp(), date_epoch))
_log('geo', end=' ', flush=True, quiet=quiet)
def download_profilepic(name, url, quiet=False):
def download_profilepic(name: str, url: str, quiet: bool = False) -> None:
"""Downloads and saves profile pic with given url."""
date_object = datetime.datetime.strptime(requests.head(url).headers["Last-Modified"], \
'%a, %d %b %Y %H:%M:%S GMT')
@@ -267,19 +279,19 @@ def download_profilepic(name, url, quiet=False):
raise ConnectionException("File \'" + url + "\' could not be downloaded.")
def get_default_session_filename(username):
def get_default_session_filename(username: str) -> str:
"""Returns default session filename for given username."""
dirname = tempfile.gettempdir() + "/" + ".instaloader-" + getpass.getuser()
filename = dirname + "/" + "session-" + username
return filename
def save_session(session, username, filename=None, quiet=False):
def save_session(session: requests.Session, username: str, filename: Optional[str] = None, quiet: bool = False) -> None:
"""Saves requests.Session object."""
if filename is None:
filename = get_default_session_filename(username)
dirname = os.path.dirname(filename)
if not os.path.exists(dirname):
if dirname != '' and not os.path.exists(dirname):
os.makedirs(dirname)
os.chmod(dirname, 0o700)
with open(filename, 'wb') as sessionfile:
@@ -288,7 +300,7 @@ def save_session(session, username, filename=None, quiet=False):
_log("Saved session to %s." % filename, quiet=quiet)
def load_session(username, filename=None, quiet=False):
def load_session(username: str, filename: Optional[str] = None, quiet: bool = False) -> requests.Session:
"""Returns loaded requests.Session object, or None if not found."""
if filename is None:
filename = get_default_session_filename(username)
@@ -304,7 +316,7 @@ def load_session(username, filename=None, quiet=False):
pass
def copy_session(session):
def copy_session(session: requests.Session) -> requests.Session:
"""Duplicates a requests.Session."""
new = requests.Session()
new.cookies = \
@@ -313,7 +325,7 @@ def copy_session(session):
return new
def test_login(session):
def test_login(session: requests.Session) -> Optional[str]:
"""Returns the Instagram username to which given requests.Session object belongs, or None."""
if session is None:
return
@@ -324,7 +336,7 @@ def test_login(session):
return data['config']['viewer']['username']
def default_http_header(empty_session_only=False):
def default_http_header(empty_session_only: bool = False) -> Dict[str, str]:
"""Returns default HTTP header we use for requests."""
user_agent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' \
'(KHTML, like Gecko) Chrome/51.0.2704.79 Safari/537.36'
@@ -347,7 +359,7 @@ def default_http_header(empty_session_only=False):
return header
def get_anonymous_session():
def get_anonymous_session() -> requests.Session:
"""Returns our default anonymous requests.Session object."""
session = requests.Session()
session.cookies.update({'sessionid' : '', 'mid' : '', 'ig_pr' : '1', \
@@ -357,7 +369,7 @@ def get_anonymous_session():
return session
def get_session(user, passwd):
def get_session(user: str, passwd: str) -> requests.Session:
"""Log in to instagram with given username and password and return session object"""
session = requests.Session()
session.cookies.update({'sessionid' : '', 'mid' : '', 'ig_pr' : '1', \
@@ -380,7 +392,7 @@ def get_session(user, passwd):
raise ConnectionException('Login error! Connection error!')
def get_feed_json(session, end_cursor=None, sleep=True):
def get_feed_json(session: requests.Session, end_cursor: str = None, sleep: bool = True) -> Dict[str, Any]:
"""
Get JSON of the user's feed.
@@ -418,25 +430,28 @@ def get_feed_json(session, end_cursor=None, sleep=True):
"profile_pic_url%2C%0A++username%0A%7D%0A&ref=feed::show"
tmpsession.headers.update(default_http_header())
tmpsession.headers.update({'Referer' : 'https://www.instagram.com/'})
tmpsession.headers.update({'Content-Type' : 'application/json'})
tmpsession.headers.update({'Content-Type' : 'application/x-www-form-urlencoded'})
resp = tmpsession.post('https://www.instagram.com/query/', data=query)
if sleep:
time.sleep(4 * random.random() + 1)
return json.loads(resp.text)
def get_location(node, session, sleep=True):
pic_json = get_json("p/" + node["code"], session, sleep=sleep)
if pic_json["entry_data"]["PostPage"][0]["media"]["location"] is not None:
def get_location(session: requests.Session, node_code: str, sleep: bool = True) -> Dict[str, str]:
pic_json = get_json("p/" + node_code, session, sleep=sleep)
media = pic_json["entry_data"]["PostPage"][0]["graphql"]["shortcode_media"] \
if "graphql" in pic_json["entry_data"]["PostPage"][0] \
else pic_json["entry_data"]["PostPage"][0]["media"]
if media["location"] is not None:
location_json = get_json("explore/locations/" +
pic_json["entry_data"]["PostPage"][0]["media"]["location"]["id"],
media["location"]["id"],
session, sleep=sleep)
return location_json["entry_data"]["LocationsPage"][0]["location"]
def download_node(node, session, name,
download_videos=True, geotags=False,
sleep=True, shorter_output=False, quiet=False):
def download_node(node: Dict[str, Any], session: requests.Session, name: str,
download_videos: bool = True, geotags: bool = False,
sleep: bool = True, shorter_output: bool = False, quiet: bool = False) -> bool:
"""
Download everything associated with one instagram node, i.e. picture, caption and video.
@@ -444,56 +459,97 @@ def download_node(node, session, name,
:param session: Session
:param name: Name of profile to which this node belongs
:param download_videos: True, if videos should be downloaded
:param geotags: Download geotags
:param sleep: Sleep between requests to instagram server
:param shorter_output: Shorten log output by not printing captions
:param quiet: Suppress output
:return: True if something was downloaded, False otherwise, i.e. file was already there
"""
downloaded = download_pic(name, node["display_src"], node["date"], quiet=quiet)
if sleep:
time.sleep(1.75 * random.random() + 0.25)
if "caption" in node:
save_caption(name, node["date"], node["caption"], shorter_output, quiet)
# pylint:disable=too-many-branches,too-many-locals
date = node["date"] if "date" in node else node["taken_at_timestamp"]
if '__typename' in node:
if node['__typename'] == 'GraphSidecar':
sidecar_data = session.get('https://www.instagram.com/p/' + node['code'] + '/', params={'__a': 1}).json()
edge_number = 1
downloaded = True
media = sidecar_data["graphql"]["shortcode_media"] if "graphql" in sidecar_data else sidecar_data["media"]
for edge in media['edge_sidecar_to_children']['edges']:
edge_downloaded = download_pic(name, edge['node']['display_url'],date,
filename_suffix=str(edge_number), quiet=quiet,
outputlabel=(str(edge_number) if edge_number != 1 else None))
downloaded = downloaded and edge_downloaded
edge_number += 1
if sleep:
time.sleep(1.75 * random.random() + 0.25)
elif node['__typename'] in ['GraphImage', 'GraphVideo']:
downloaded = download_pic(name, node["display_url"] if "display_url" in node else node["display_src"],
date, quiet=quiet)
if sleep:
time.sleep(1.75 * random.random() + 0.25)
else:
_log("Warning: Unknown typename discovered:" + node['__typename'])
downloaded = False
else:
# Node is an old image or video.
downloaded = download_pic(name, node["display_src"], date, quiet=quiet)
if sleep:
time.sleep(1.75 * random.random() + 0.25)
if "edge_media_to_caption" in node and node["edge_media_to_caption"]["edges"]:
save_caption(name, date, node["edge_media_to_caption"]["edges"][0]["node"]["text"], shorter_output, quiet)
elif "caption" in node:
save_caption(name, date, node["caption"], shorter_output, quiet)
else:
_log("<no caption>", end=' ', flush=True, quiet=quiet)
node_code = node['shortcode'] if 'shortcode' in node else node['code']
if node["is_video"] and download_videos:
video_data = get_json('p/' + node["code"], session, sleep=sleep)
video_data = get_json('p/' + node_code, session, sleep=sleep)
download_pic(name,
video_data['entry_data']['PostPage'][0]['media']['video_url'],
node["date"], 'mp4', quiet=quiet)
video_data['entry_data']['PostPage'][0]['graphql']['shortcode_media']['video_url'],
date, 'mp4', quiet=quiet)
if geotags:
location = get_location(node, session, sleep)
location = get_location(session, node_code, sleep)
if location:
save_location(name, location, node["date"])
save_location(name, location, date, quiet=quiet)
_log(quiet=quiet)
return downloaded
def download_feed_pics(session, max_count=None, fast_update=False, filter_func=None,
download_videos=True, geotags=False,
shorter_output=False, sleep=True, quiet=False):
def download_feed_pics(session: requests.Session, max_count: int = None, fast_update: bool = False,
filter_func: Optional[Callable[[Dict[str, Dict[str, Any]]], bool]] = None,
download_videos: bool = True, geotags: bool = False,
shorter_output: bool = False, sleep: bool = True, quiet: bool = False) -> None:
"""
Download pictures from the user's feed.
Example to download up to the 20 pics the user last liked:
>>> download_feed_pics(load_session('USER'), max_count=20, fast_update=True,
>>> filter_func=lambda node: not node["likes"]["viewer_has_liked"])
>>> filter_func=lambda node:
>>> not node["likes"]["viewer_has_liked"] if "likes" in node else not node["viewer_has_liked"])
:param session: Session belonging to a user, i.e. not an anonymous session
:param max_count: Maximum count of pictures to download
:param fast_update: If true, abort when first already-downloaded picture is encountered
:param filter_func: function(node), which returns True if given picture should not be downloaded
:param download_videos: True, if videos should be downloaded
:param geotags: Download geotags
:param shorter_output: Shorten log output by not printing captions
:param sleep: Sleep between requests to instagram server
:param quiet: Suppress output
"""
# pylint:disable=too-many-locals
data = get_feed_json(session, sleep=sleep)
count = 1
while data["feed"]["media"]["page_info"]["has_next_page"]:
for node in data["feed"]["media"]["nodes"]:
while True:
if "graphql" in data:
is_edge = True
feed = data["graphql"]["user"]["edge_web_feed_timeline"]
else:
is_edge = False
feed = data["feed"]["media"]
for edge_or_node in feed["edges"] if is_edge else feed["nodes"]:
if max_count is not None and count > max_count:
return
node = edge_or_node["node"] if is_edge else edge_or_node
name = node["owner"]["username"]
if filter_func is not None and filter_func(node):
_log("<pic by %s skipped>" % name, flush=True, quiet=quiet)
@@ -505,11 +561,62 @@ def download_feed_pics(session, max_count=None, fast_update=False, filter_func=N
sleep=sleep, shorter_output=shorter_output, quiet=quiet)
if fast_update and not downloaded:
return
data = get_feed_json(session, end_cursor=data["feed"]["media"]["page_info"]["end_cursor"],
sleep=sleep)
if not feed["page_info"]["has_next_page"]:
break
data = get_feed_json(session, end_cursor=feed["page_info"]["end_cursor"], sleep=sleep)
def check_id(profile, session, json_data, quiet=False):
def get_hashtag_json(hashtag: str, session: requests.Session,
max_id: Optional[str] = None, sleep: bool = True) -> Optional[Dict[str, Any]]:
"""Return JSON of a #hashtag"""
return get_json(name='explore/tags/{0}/'.format(hashtag), session=session, max_id=max_id, sleep=sleep)
def download_hashtag(hashtag: str, session: requests.Session,
max_count: Optional[int] = None,
filter_func: Optional[Callable[[Dict[str, Dict[str, Any]]], bool]] = None,
fast_update: bool = False, download_videos: bool = True, geotags: bool = False,
shorter_output: bool = False, sleep: bool = True, quiet: bool = False) -> None:
"""Download pictures of one hashtag.
To download the last 30 pictures with hashtag #cat, do
>>> download_hashtag('cat', session=get_anonymous_session(), max_count=30)
:param hashtag: Hashtag to download, without leading '#'
:param session: Session belonging to a user, i.e. not an anonymous session
:param max_count: Maximum count of pictures to download
:param filter_func: function(node), which returns True if given picture should not be downloaded
:param fast_update: If true, abort when first already-downloaded picture is encountered
:param download_videos: True, if videos should be downloaded
:param geotags: Download geotags
:param shorter_output: Shorten log output by not printing captions
:param sleep: Sleep between requests to instagram server
:param quiet: Suppress output
"""
data = get_hashtag_json(hashtag, session, sleep=sleep)
count = 1
while data:
for node in data['entry_data']['TagPage'][0]['tag']['media']['nodes']:
if max_count is not None and count > max_count:
return
_log('[{0:3d}] #{1} '.format(count, hashtag), end='', flush=True, quiet=quiet)
if filter_func is not None and filter_func(node):
_log('<skipped>', quiet=quiet)
continue
count += 1
downloaded = download_node(node, session, '#{0}'.format(hashtag),
download_videos=download_videos, geotags=geotags, sleep=sleep,
shorter_output=shorter_output, quiet=quiet)
if fast_update and not downloaded:
return
if data['entry_data']['TagPage'][0]['tag']['media']['page_info']['has_next_page']:
data = get_hashtag_json(hashtag, session, sleep=sleep,
max_id=data['entry_data']['TagPage'][0]['tag']['media']['page_info']['end_cursor'])
else:
break
def check_id(profile: str, session: requests.Session, json_data: Dict[str, Any], quiet: bool = False) -> str:
"""
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.
@@ -546,8 +653,10 @@ def check_id(profile, session, json_data, quiet=False):
raise ProfileNotExistsException("Profile {0} does not exist.".format(profile))
def download(name, session, profile_pic_only=False, download_videos=True, geotags=False,
fast_update=False, shorter_output=False, sleep=True, quiet=False):
def download(name: str, session: requests.Session,
profile_pic_only: bool = False, download_videos: bool = True, geotags: bool = False,
fast_update: bool = False, shorter_output: bool = False, sleep: bool = True,
quiet: bool = False) -> None:
"""Download one profile"""
# pylint:disable=too-many-branches,too-many-locals
# Get profile main page json
@@ -575,17 +684,13 @@ def download(name, session, profile_pic_only=False, download_videos=True, geotag
if data["config"]["viewer"] is not None:
_log("profile %s could also be downloaded anonymously." % name, quiet=quiet)
if ("nodes" not in data["entry_data"]["ProfilePage"][0]["user"]["media"] or
len(data["entry_data"]["ProfilePage"][0]["user"]["media"]["nodes"]) == 0) \
not data["entry_data"]["ProfilePage"][0]["user"]["media"]["nodes"]) \
and not profile_pic_only:
raise ProfileHasNoPicsException("Profile %s: no pics found." % name)
# Iterate over pictures and download them
def get_last_id(data):
if len(data["entry_data"]) == 0 or \
len(data["entry_data"]["ProfilePage"][0]["user"]["media"]["nodes"]) == 0:
return None
else:
data = data["entry_data"]["ProfilePage"][0]["user"]["media"]["nodes"]
return int(data[len(data) - 1]["id"])
if data["entry_data"] and data["entry_data"]["ProfilePage"][0]["user"]["media"]["nodes"]:
return data["entry_data"]["ProfilePage"][0]["user"]["media"]["nodes"][-1]["id"]
totalcount = data["entry_data"]["ProfilePage"][0]["user"]["media"]["count"]
count = 1
while get_last_id(data) is not None:
@@ -599,7 +704,8 @@ def download(name, session, profile_pic_only=False, download_videos=True, geotag
return
data = get_json(name, session, max_id=get_last_id(data), sleep=sleep)
def get_logged_in_session(username, password=None, quiet=False):
def get_logged_in_session(username: str, password: Optional[str] = None, quiet: bool = False) -> requests.Session:
"""Logs in and returns session, asking user for password if needed"""
if password is not None:
return get_session(username, password)
@@ -614,9 +720,12 @@ def get_logged_in_session(username, password=None, quiet=False):
print(err, file=sys.stderr)
password = None
def download_profiles(profilelist, username=None, password=None, sessionfile=None,
profile_pic_only=False, download_videos=True, geotags=False, fast_update=False,
sleep=True, shorter_output=False, quiet=False):
def download_profiles(profilelist: List[str], username: Optional[str] = None, password: Optional[str] = None,
sessionfile: Optional[str] = None, max_count: Optional[int] = None,
profile_pic_only: bool = False, download_videos: bool = True, geotags: bool = False,
fast_update: bool = False,
sleep: bool = True, shorter_output: bool = False, quiet: bool = False) -> None:
"""Download set of profiles and handle sessions"""
# pylint:disable=too-many-branches,too-many-locals
# Login, if desired
@@ -633,38 +742,52 @@ def download_profiles(profilelist, username=None, password=None, sessionfile=Non
try:
# Generate set of targets
for pentry in profilelist:
if pentry[0] == '@':
if pentry[0] == '#':
_log("Retrieving pictures with hashtag {0}".format(pentry), quiet=quiet)
download_hashtag(hashtag=pentry[1:], session=session, max_count=max_count, fast_update=fast_update,
download_videos=download_videos, geotags=geotags, shorter_output=shorter_output,
sleep=sleep, quiet=quiet)
elif pentry[0] == '@' and username is not None:
_log("Retrieving followees of %s..." % pentry[1:], quiet=quiet)
followees = get_followees(pentry[1:], session)
targets.update([followee['username'] for followee in followees])
elif pentry == ":feed-all" and username is not None:
_log("Retrieving pictures from your feed...", quiet=quiet)
download_feed_pics(session, fast_update=fast_update,
download_feed_pics(session, fast_update=fast_update, max_count=max_count,
download_videos=download_videos, geotags=geotags,
shorter_output=shorter_output, sleep=sleep, quiet=quiet)
elif pentry == ":feed-liked" and username is not None:
_log("Retrieving pictures you liked from your feed...", quiet=quiet)
download_feed_pics(session, fast_update=fast_update,
filter_func=lambda node: not node["likes"]["viewer_has_liked"],
download_feed_pics(session, fast_update=fast_update, max_count=max_count,
filter_func=lambda node:
not node["likes"]["viewer_has_liked"]
if "likes" in node
else not node["viewer_has_liked"],
download_videos=download_videos, geotags=geotags,
shorter_output=shorter_output, sleep=sleep, quiet=quiet)
else:
targets.add(pentry)
if len(targets) == 0:
_log("No profiles to download given.", quiet=quiet)
elif len(targets) > 1:
if len(targets) > 1:
_log("Downloading %i profiles..." % len(targets), quiet=quiet)
# Iterate through targets list and download them
for target in targets:
try:
download(target, session, profile_pic_only, download_videos,
geotags, fast_update, shorter_output, sleep, quiet)
try:
download(target, session, profile_pic_only, download_videos,
geotags, fast_update, shorter_output, sleep, quiet)
except ProfileNotExistsException as err:
if username is not None:
_log("\"Profile not exists\" - Trying again anonymously, helps in case you are just blocked")
download(target, get_anonymous_session(), profile_pic_only, download_videos,
geotags, fast_update, shorter_output, sleep, quiet)
else:
raise err
except NonfatalException as err:
failedtargets.append(target)
print(err, file=sys.stderr)
except KeyboardInterrupt:
print("\nInterrupted by user.", file=sys.stderr)
if len(targets) > 1 and len(failedtargets) > 0:
if len(targets) > 1 and failedtargets:
print("Errors occured (see above) while downloading profiles: %s." %
", ".join(failedtargets), file=sys.stderr)
# Save session if it is useful
@@ -672,11 +795,13 @@ def download_profiles(profilelist, username=None, password=None, sessionfile=Non
save_session(session, username, sessionfile, quiet=quiet)
def main():
parser = ArgumentParser(description=__doc__)
parser.add_argument('profile', nargs='*',
help='Name of profile to download; @<profile> to download all followees of '
parser = ArgumentParser(description=__doc__,
epilog="Report issues at https://github.com/Thammus/instaloader/issues.")
parser.add_argument('profile', nargs='*', metavar='profile|#hashtag',
help='Name of profile or #hashtag to download. '
'Alternatively, if --login is given: @<profile> to download all followees of '
'<profile>; or the special targets :feed-all or :feed-liked to '
'download pictures from your feed if --login is given (using '
'download pictures from your feed (using '
'--fast-update is recommended).')
parser.add_argument('--version', action='version',
version=__version__)
@@ -700,6 +825,9 @@ def main():
help='Store geotags when available')
parser.add_argument('-F', '--fast-update', action='store_true',
help='Abort at encounter of first already-downloaded picture')
parser.add_argument('-c', '--count',
help='Do not attempt to download more than COUNT posts. '
'Applies only to #hashtag, :feed-all and :feed-liked.')
parser.add_argument('-S', '--no-sleep', action='store_true',
help='Do not sleep between actual downloads of pictures')
parser.add_argument('-O', '--shorter-output', action='store_true',
@@ -710,8 +838,9 @@ def main():
args = parser.parse_args()
try:
download_profiles(args.profile, args.login, args.password, args.sessionfile,
args.profile_pic_only, not args.skip_videos, args.geotags, args.fast_update,
not args.no_sleep, args.shorter_output, args.quiet)
int(args.count) if args.count is not None else None,
args.profile_pic_only, not args.skip_videos, args.geotags, args.fast_update,
not args.no_sleep, args.shorter_output, args.quiet)
except InstaloaderException as err:
raise SystemExit("Fatal error: %s" % err)
+4 -6
View File
@@ -3,12 +3,12 @@
import sys
from setuptools import setup
if sys.version_info < (3, 3):
sys.exit('Instaloader requires Python >= 3.3.')
if sys.version_info < (3, 5):
sys.exit('Instaloader requires Python >= 3.5.')
setup(
name='instaloader',
version='1.2',
version='1.3',
py_modules=['instaloader'],
url='https://github.com/Thammus/instaloader',
license='MIT',
@@ -19,7 +19,7 @@ setup(
'profile.',
long_description=open('README.rst').read(),
install_requires=['requests>=2.4'],
python_requires='>=3.3',
python_requires='>=3.5',
entry_points={'console_scripts': ['instaloader=instaloader:main']},
zip_safe=True,
keywords='instagram downloader',
@@ -29,8 +29,6 @@ setup(
'Intended Audience :: End Users/Desktop',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3 :: Only',