Compare commits

..
20 Commits
Author SHA1 Message Date
André Koch-Kramer 3f4c12b54b Release of version 4.2.2 2019-02-10 21:13:29 +01:00
Alexander Graf dcc57f11c4 Fix falsely-raised PostChangedException 2019-02-10 20:55:07 +01:00
Alexander Graf a9e79e90f9 Fix retry logic for download of files
This fixes #235. The retry logic has been broken with commit
4bc0a94e12 in version 4.2.1.
2019-02-10 20:29:04 +01:00
André Koch-Kramer a0b7804fd2 Properly handle redirected posts
Resolves #225.
2019-02-10 18:24:33 +01:00
André Koch-Kramer 9e04ef3436 New Profile.from_id() that works without posts
The old method needed the target profile to have at least one post.
The new method works anonymously even for private profiles.
Closes #249.
2019-02-10 17:46:18 +01:00
Alexander Graf 34416298a0 profiles: First load profilepic, then save json
This way, the iphone_struct information is included in saved profile pictures.
2019-01-24 22:40:36 +01:00
Alexander Graf e39ffecea4 Minor doc changes
- Add saythanks.io link
- Set lang="en" attribute in html tag
2019-01-24 22:39:58 +01:00
Alexander Graf 15fe3d5f2d doc: versionadded directive to new {write,get}_raw 2019-01-17 18:39:54 +01:00
Alexander Graf c73b10843f Update year in LICENSE file 2019-01-17 18:12:32 +01:00
Alexander Graf b158c57b9e Unittest for HD profilepic and fix ResourceWarning 2019-01-17 18:10:14 +01:00
Alexander Graf 57892f191a Release of version 4.2.1 2019-01-17 17:47:45 +01:00
Alexander Graf 26e74bad6a Download HD profile picture if logged in
Unfortunately, it is now required to be logged in to access the HD version of
profile pictures. When attempting to download profile pictures without --login,
a warning message is printed once, and the lower-quality versions are obtained.

For backwards compatibility, already-downloaded profile pictures are
overwritten if the now-obtainable version is assumed to be of better quality
than the existing one (determined by file size vs Content-Length).

The iPhone endpoint is accessed with code exhumed from
c355338010. Also, this reverts "Profile: don't
access removed iphone info endpoint" 08327c4117.

This fixes #209.
2019-01-17 17:39:29 +01:00
Alexander Graf 27a239f07f doc theme: Improve consistency of code highlight 2019-01-17 11:39:18 +01:00
Alexander Graf bb25f03c7d doc: "Troubleshooting" section + minor changes 2019-01-17 11:00:04 +01:00
Lars Lindqvist c54342bb98 Refactor get_filename
Use string formatting instead of `str(0) + str(index)` and don't assume file extension is three characters.
2019-01-08 19:38:46 +01:00
Alexander Graf d2bebcd00c doc: Fix +args.txt description
- Fix wrong `--option value`, which must be `--option=value`
- Fix typos

Fixes #221.
2019-01-03 17:05:43 +01:00
Alexander Graf 4bc0a94e12 Name profilepic by hash if Last-Modified missing
This fixes #188.

Also, this commit prevents double-requesting the profile pic URL to obtain the
Last-Modified header.
2019-01-03 16:49:21 +01:00
Alexander Graf 06845b53fc Fixes and enhancements to documentation theme 2019-01-01 14:23:08 +01:00
Alexander Graf e9cec0b261 docs/conf.py: remove unneeded import 2018-12-31 19:35:55 +01:00
Alexander Graf ac4cd9f595 New doc design: Sphinx Bootstrap 4 Theme
Now we use an own, mobile-first, responsive sphinx theme based on Bootstrap 4.
It makes navigation on the very long "Python module instaloader" page a lot
easier. Also, it looks better.

This solves all the problems we had with sphinx-bootstrap-theme.
2018-12-31 19:30:19 +01:00
28 changed files with 699 additions and 216 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2016-2018 Alexander Graf and André Koch-Kramer.
Copyright (c) 2016-2019 Alexander Graf and André Koch-Kramer.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+75
View File
@@ -0,0 +1,75 @@
@import url(bootstrap-4.1.3.min.css);
a {
color: #008d06; }
a:hover, a.current, a.active {
color: #f48400; }
a.current, a.active {
font-weight: 500; }
.doc-sidebar a:hover, .modal-body a:hover {
text-decoration: none; }
code {
color: #212529; }
.highlight {
border-radius: 0.3em; }
.highlight pre {
padding: 0.7em;
color: #fff; }
.highlight .c1 {
color: #008d06; }
.highlight .k, .highlight .kn, .highlight .ow {
color: #f48400;
font-weight: bold; }
.highlight .nb {
color: #f48400; }
.highlight .s1, .highlight .s2, .highlight .mi {
color: #ff7efd; }
.doc-content {
padding-top: 0.5em;
top: 3.5625em;
position: fixed;
bottom: 0;
overflow-y: auto; }
.doc-sidebar {
position: fixed;
padding-top: 0.5em;
top: 3.5625em;
bottom: 0;
right: 0;
overflow-y: auto; }
.doc-sidebar ul {
list-style-type: none;
padding-left: 1em; }
.doc-sidebar ul.current {
padding-left: 0; }
.doc-sidebar li {
padding-top: 0.2em;
padding-bottom: 0.2em; }
.admonition {
border-radius: 0.3em;
border: 1px solid #ccc;
background-color: #f8f9fa; }
.admonition .admonition-title {
color: #ff7efd; }
.topic {
border-radius: 0.3em;
background-color: #f8f9fa; }
.nav-link {
padding-top: 0;
padding-bottom: 0; }
.btn-success {
background-color: #008d06;
border-color: #008d06; }
.btn-success:hover {
background-color: #f48400;
border-color: #f48400; }
/*# sourceMappingURL=instaloader.css.map */
+22
View File
@@ -0,0 +1,22 @@
$(function () {
$('[data-toggle="tooltip"]').tooltip();
$('.doc-sidebar > ul > li.current').attr("id", "localtoc");
$('#localtoc ul').addClass("nav flex-column");
$('#localtoc ul li').addClass("nav-item");
$('#localtoc ul li a').addClass("nav-link");
$('.doc-content').scrollspy({target: '#localtoc'});
const top_href = '#' + $('.section:first').attr("id");
$('#localtoc > a.current').attr("href", top_href);
$('#navbarToc a').on("click", function () {
const href = $(this).attr("href");
if (href === '#') {
window.location.href = top_href;
} else {
window.location.href = href;
}
$('#navbarToc').modal('hide');
});
});
+117
View File
@@ -0,0 +1,117 @@
@import 'bootstrap-4.1.3.min.css';
$color_instaloader_main: #008d06;
$color_instaloader_accent: #f48400;
$color_instaloader_hyper: #ff7efd;
$color_normal_text: #212529;
$color_light_bg: #f8f9fa;
a {
color: $color_instaloader_main;
&:hover, &.current, &.active {
color: $color_instaloader_accent;
}
&.current, &.active {
font-weight: 500;
}
}
.doc-sidebar, .modal-body {
a:hover {
text-decoration: none
}
}
code {
color: $color_normal_text;
}
.highlight {
border-radius: 0.3em;
pre {
padding: 0.7em;
color: #fff;
}
.c1 {
color: $color_instaloader_main
}
.k, .kn, .ow {
color: $color_instaloader_accent;
font-weight: bold
}
.nb {
color: $color_instaloader_accent
}
.s1, .s2, .mi {
color: $color_instaloader_hyper
}
}
.doc-content {
padding-top: 0.5em;
top: 3.5625em;
position: fixed;
bottom: 0;
overflow-y: auto;
}
.doc-sidebar {
position: fixed;
padding-top: 0.5em;
top: 3.5625em;
bottom: 0;
right: 0;
overflow-y: auto;
ul {
list-style-type: none;
padding-left: 1em;
&.current {
padding-left: 0;
}
}
li {
padding-top: 0.2em;
padding-bottom: 0.2em;
}
}
.admonition {
border-radius: 0.3em;
border: 1px solid #ccc;
background-color: $color_light_bg;
.admonition-title {
color: $color_instaloader_hyper;
}
}
.topic {
border-radius: 0.3em;
background-color: $color_light_bg;
}
.nav-link {
padding-top: 0;
padding-bottom: 0;
}
.btn-success {
background-color: $color_instaloader_main;
border-color: $color_instaloader_main;
&:hover {
background-color: $color_instaloader_accent;
border-color: $color_instaloader_accent;
}
}
-28
View File
@@ -1,28 +0,0 @@
code {
color: #222;
background-color: #f5f5f5;
}
dd {
padding-left: 8px;
}
dl.option dd {
padding-left: 12px;
}
a {
color: #008d06;
}
a:hover {
color: #f48400;
}
body {
font-size: 16px;
}
.versionmodified {
font-size: 14px;
}
-14
View File
@@ -1,14 +0,0 @@
<li class="dropdown globaltoc-container">
<a role="button"
id="dLabelGlobalToc"
data-toggle="dropdown"
data-target="#"
href="{{ pathto(master_doc) }}">{{ theme_navbar_site_name }} <b class="caret"></b></a>
<ul class="dropdown-menu globaltoc"
role="menu"
aria-labelledby="dLabelGlobalToc">
{{ toctree(maxdepth=theme_globaltoc_depth|toint, collapse=False, includehidden=theme_globaltoc_includehidden|tobool) }}
<li role="separator" class="divider"></li>
<li class="toctree-l1"><a class="reference internal" href="genindex.html">Index</a></li>
</ul>
</li>
+107 -7
View File
@@ -1,18 +1,118 @@
{% extends "!layout.html" %}
{% set html5_doctype = True %}
{% set script_files = script_files + ['_static/bootstrap-4.1.3.bundle.min.js', '_static/instaloader.js'] %}
{% block htmltitle %}
{% if pagename == "index" %}
<title>{{ title|striptags|e }}</title>
<title>{{ title|striptags|e }}</title>
{% else %}
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
{% endif %}
{% endblock %}
{% block extrahead %}
{{ super() }}
<meta name="google-site-verification" content="FNUsDdkOJM9obm9QEdrNBVd9AtAzHzA-aaSMCUrS6C8" />
{{ super() }}
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="google-site-verification" content="FNUsDdkOJM9obm9QEdrNBVd9AtAzHzA-aaSMCUrS6C8"/>
{% if pagename == "index" %}
<link rel="canonical" href="https://instaloader.github.io/" />
<link rel="canonical" href="https://instaloader.github.io/"/>
{% else %}
<link rel="canonical" href="https://instaloader.github.io/{{ pagename }}.html" />
<link rel="canonical" href="https://instaloader.github.io/{{ pagename }}.html"/>
{% endif %}
<script async defer src="https://buttons.github.io/buttons.js"></script>
{% endblock %}
{% block header %}
<nav class="navbar navbar-expand-sm navbar-light bg-light fixed-top border-bottom">
<a class="navbar-brand" href="{{ pathto(master_doc) }}">
<img src="{{ pathto('_static/' + logo, 1) }}" width="30" height="30" class="d-inline-block align-top" alt="logo">
Instaloader
</a>
<ul class="navbar-nav mr-auto">
<li class="nav-item ml-0 ml-sm-2 ml-md-0">
<a target="_blank" class="nav-link"
href="https://github.com/instaloader/instaloader/releases/tag/v{{ current_release }}"
title="Released on {{ current_release_date }}" data-toggle="tooltip">v<b>{{ current_release }}</b></a>
</li>
</ul>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarLinks"
aria-controls="navbarLinks" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarLinks">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a target="_blank" class="nav-link" href="https://github.com/instaloader/instaloader/issues">Issues</a>
</li>
<li class="nav-item">
<a target="_blank" class="nav-link" href="https://github.com/instaloader/instaloader/releases">Releases</a>
</li>
<li class="nav-item">
<a target="_blank" class="nav-link" href="https://github.com/instaloader/instaloader">GitHub</a>
</li>
<li class="nav-item d-sm-none d-lg-block">
<a target="_blank" class="nav-link" href="https://saythanks.io/to/aandergr">Say Thanks!</a>
</li>
</ul>
<form class="form-inline" action="search.html" method="get">
<div class="input-group my-2 my-sm-0 ml-2">
<input class="form-control" type="search" placeholder="Search" aria-label="Search" name="q">
<div class="input-group-append">
<button class="btn btn-success btn-small" type="submit">Go</button>
</div>
</div>
</form>
</div>
{% if pagename != "index" %}
<button type="button" class="btn btn-success d-md-none btn-small ml-2" data-toggle="modal"
data-target="#navbarToc">
TOC
</button>
{% endif %}
</nav>
{% if pagename != "index" %}
<div class="modal fade" id="navbarToc" tabindex="-1" role="dialog"
aria-hidden="true" aria-labelledby="navbarTocTitle">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="navbarTocTitle">Contents</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{{ toctree(maxdepth=-1) }}
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block relbar1 %}{% endblock %}
{% block relbar2 %}{% endblock %}
{% block footer %}{% endblock %}
{% block content %}
<div class="container-fluid row">
<div class="col-12 {% if pagename != "index" %}col-md-9 col-xl-10{% endif %} doc-content">
{% block body %}{% endblock %}
</div>
{% if pagename != "index" %}
<div class="d-none col-md-3 col-xl-2 bg-light doc-sidebar d-md-flex flex-column border-left">
<h3>{{ _('Table of Contents') }}</h3>
{{ toctree(maxdepth=-1) }}
<div class="d-flex justify-content-between small mt-auto mb-2">
<a class="reference internal{% if pagename == "genindex" %} current{% endif %}" href="genindex.html">Index</a>
{% if pagename != "genindex" and pagename != "search" %}
<a class="reference external"
href="https://github.com/instaloader/instaloader/edit/master/docs/{{ pagename }}.rst">Edit this page</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}
-60
View File
@@ -1,60 +0,0 @@
<div id="navbar" class="{{ theme_navbar_class }} navbar-default {% if theme_navbar_fixed_top|tobool -%} navbar-fixed-top{%- endif -%}">
<div class="container">
<div class="navbar-header">
<!-- .btn-navbar is used as the toggle for collapsed navbar content -->
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".nav-collapse">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{{ pathto(master_doc) }}">
{%- block sidebarlogo %}
{%- if logo %}<span><img src="{{ pathto('_static/' + logo, 1) }}"></span>{%- endif %}
{%- endblock %}
{% if theme_navbar_title -%}{{ theme_navbar_title|e }}{%- else -%}{{ project|e }}{%- endif -%}
</a>
<span class="navbar-text navbar-version pull-left">
<a href="https://github.com/instaloader/instaloader/releases/tag/v{{current_release}}" title="Released on {{current_release_date}}">v<b>{{current_release}}</b></a>
</span>
</div>
<div class="collapse navbar-collapse nav-collapse">
<ul class="nav navbar-nav">
{% if theme_navbar_links %}
{%- for link in theme_navbar_links %}
<li><a href="{{ pathto(*link[1:]) }}">{{ link[0] }}</a></li>
{%- endfor %}
{% endif %}
{% block navbartoc %}
{% include "globaltoc.html" %}
{% if theme_navbar_pagenav and pagename not in ("index", "genindex", "installation") %}
{% include "navbartoc.html" %}
{% endif %}
{% endblock %}
{% if theme_navbar_sidebarrel %}
{% block sidebarrel %}
{% include "relations.html" %}
{% endblock %}
{% endif %}
</ul>
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a role="button" id="dLabelUsefulLinks" data-toggle="dropdown" data-target="#" href="#">
Useful Links
<b class="caret"></b>
</a>
<ul class="dropdown-menu" role="menu" aria-labelledby="dLabelUsefulLinks">
<li><a href="https://github.com/instaloader/instaloader/issues">Issue Tracker</a></li>
<li><a href="https://github.com/instaloader/instaloader/releases">Version History</a></li>
<li><a href="https://pypi.org/project/instaloader/">Instaloader on PyPI</a></li>
<li><a href="https://github.com/instaloader/instaloader">Instaloader on GitHub</a></li>
<li role="separator" class="divider"></li>
<li style="padding-left: 20px;"><a class="github-button" href="https://github.com/instaloader/instaloader" data-icon="octicon-star" data-size="large" data-show-count="true" aria-label="Star Instaloader on GitHub">Star</a></li>
<li role="separator" class="divider"></li>
<li><a href="https://www.paypal.me/aandergr"><b>Donate (via Paypal)</b></a></li>
</ul>
</li>
</ul>
</div>
</div>
</div>
+28
View File
@@ -0,0 +1,28 @@
{% extends "!search.html" %}
{% block body %}
<div id="fallback" class="admonition warning">
<script type="text/javascript">$('#fallback').hide();</script>
<p>
{% trans %}Please activate JavaScript to enable the search
functionality.{% endtrans %}
</p>
</div>
{% if search_performed %}
<h1>{{ _('Search Results') }}</h1>
{% if not search_results %}
<p>{{ _('Your search did not match any documents. Please make sure that all words are spelled correctly and that you\'ve selected enough categories.') }}</p>
{% endif %}
{% endif %}
<div id="search-results">
{% if search_results %}
<ul>
{% for href, caption, context in search_results %}
<li><a href="{{ pathto(item.href) }}">{{ caption }}</a>
<div class="context">{{ context|e }}</div>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endblock %}
+2
View File
@@ -198,6 +198,8 @@ Exceptions
.. autoexception:: BadCredentialsException
.. autoexception:: PostChangedException
.. autoexception:: TooManyRequestsException
``InstaloaderContext`` (Low-level functions)
+8 -6
View File
@@ -249,18 +249,20 @@ Miscellaneous Options
.. option:: +args.txt
Read arguments from file `args.txt`, a shortcut to provide argument from
file rather than command-line. This provide a convient way to hide login
info from CLI. and also can use for simplify managment of long arguments.
Read arguments from file `args.txt`, a shortcut to provide arguments from
file rather than command-line. This provides a convenient way to hide login
info from CLI, and can also be used to simplify managment of long arguments.
.. note::
text file should separate arg with line break.
Text file should separate arguments with line breaks.
args.txt example::
--login MYUSENAME
--password MYPASSWORD
--login=MYUSERNAME
--password=MYPASSWORD
--fast-update
profile1
profile2
.. versionadded:: 4.1
+9
View File
@@ -82,6 +82,15 @@ See also :class:`Post`, :meth:`Instaloader.download_post`,
Discussed in :issue:`113`.
Top X Posts of User
-------------------
With Instaloader, it is easy to download the few most-liked pictres of a user.
.. literalinclude:: codesnippets/194_top_x_of_user.py
Discussed in :issue:`194`.
Upgrade Images by Local Copies
------------------------------
+15
View File
@@ -0,0 +1,15 @@
from itertools import islice
from math import ceil
from instaloader import Instaloader, Profile
PROFILE = ... # profile to download from
X_percentage = 10 # percentage of posts that should be downloaded
L = Instaloader()
profile = Profile.from_username(L.context, PROFILE)
posts_sorted_by_likes = sorted(profile.get_posts(), key = lambda p: p.likes + p.comments)
for post in islice(posts_sorted_by_likes, ceil(profile.mediacount * X_percentage / 100)):
L.download_post(post, PROFILE)
@@ -0,0 +1,23 @@
from glob import glob
from os.path import expanduser
from sqlite3 import connect
from instaloader import ConnectionException, Instaloader
# FIREFOXCOOKIEFILE = "/home/alex/.mozilla/firefox/l96w6b90.default/cookies.sqlite"
FIREFOXCOOKIEFILE = glob(expanduser("~/.mozilla/firefox/*.default/cookies.sqlite"))[0]
instaloader = Instaloader(max_connection_attempts=1)
instaloader.context._session.cookies.update(connect(FIREFOXCOOKIEFILE)
.execute("SELECT name, value FROM moz_cookies "
"WHERE baseDomain='instagram.com'"))
try:
username = instaloader.test_login()
if not username:
raise ConnectionException()
except ConnectionException:
raise SystemExit("Cookie import failed. Are you logged in successfully in Firefox?")
instaloader.context.username = username
instaloader.save_session_to_file()
+4 -19
View File
@@ -22,8 +22,6 @@ import subprocess
import sys
import typing
import sphinx_bootstrap_theme
sys.path.insert(0, os.path.abspath('..'))
import docs.sphinx_autodoc_typehints as sphinx_autodoc_typehints
@@ -88,7 +86,7 @@ author = 'Alexander Graf and Andre Koch-Kramer'
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
language = "en"
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
@@ -124,7 +122,7 @@ add_module_names = False
# show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
pygments_style = 'fruity'
# A list of ignored prefixes for module index sorting.
# modindex_common_prefix = []
@@ -141,20 +139,7 @@ todo_include_todos = False
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'bootstrap'
html_theme_path = sphinx_bootstrap_theme.get_html_theme_path()
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#
html_theme_options = {
'navbar_site_name': 'Site Contents',
'navbar_pagenav_name': 'Page Contents',
'navbar_pagenav': True,
'navbar_sidebarrel': True,
'nosidebar': True,
}
html_theme = 'basic'
# Add any paths that contain custom themes here, relative to this directory.
# html_theme_path = []
@@ -376,6 +361,6 @@ html_context = {'current_release': current_release, 'current_release_date': curr
def setup(app):
typing.TYPE_CHECKING = True
app.add_stylesheet("style.css")
app.add_stylesheet("instaloader.css")
app.connect('autodoc-process-signature', sphinx_autodoc_typehints.process_signature)
app.connect('autodoc-process-docstring', sphinx_autodoc_typehints.process_docstring)
+3 -1
View File
@@ -17,12 +17,14 @@ Reporting Bugs
If you encounter a bug, do not hesitate to report it in our
`Issue Tracker <https://github.com/instaloader/instaloader/issues>`__. Since
Instaloader is actively developed, the majority of bugs is fixed within only
**3 days** after being reported. When reporting a problem, please keep the
**4 days** after being reported. When reporting a problem, please keep the
following in mind:
- Ensure you **use the latest version** of Instaloader. The currently-installed
version can be found out with ``instaloader --version``.
- Check whether there is a valid solution in our :ref:`troubleshooting` section.
- Briefly **check whether the bug has already been reported**. If you find an
issue reporting the same bug you encountered, comment there rather than
opening a new issue. However, if unsure, please create a new issue.
+1
View File
@@ -60,6 +60,7 @@ Instaloader Documentation
cli-options
as-module
codesnippets
troubleshooting
contributing
Useful Links
-1
View File
@@ -1,3 +1,2 @@
requests
sphinx!=1.7.7
sphinx-bootstrap-theme
+60
View File
@@ -0,0 +1,60 @@
.. _troubleshooting:
Troubleshooting
===============
.. highlight:: python
429 - Too Many Requests
-----------------------
Instaloader has a logic to keep track of its requests to Instagram and to obey
their rate limits. Since they are nowhere documented, we try them out
experimentally. We have a daily cron job running to confirm that Instaloader
still stays within the rate limits. Nevertheless, the rate control logic assumes
that
- at one time, Instaloader is the only application that consumes requests. I.e.
neither the Instagram browser interface, nor a mobile app, nor another
Instaloader instance is running in parallel,
- no requests had been consumed when Instaloader starts.
The latter one implies that restarting or reinstantiating Instaloader often
within short time is prone to cause a 429. When a request is denied with a 429,
Instaloader retries the request as soon as the temporary ban is assumed to be
expired. In case the retry continuously fails for some reason, which should not
happen in normal conditions, consider adjusting the
:option:`--max-connection-attempts` option.
**"Too many queries in the last time"** is not an error. It is a notice that the
rate limit has almost been reached, according to Instaloader's own rate
accounting mechanism. We regularly adjust this mechanism to match Instagram's
current rate limiting.
Login error
-----------
Instaloader's login *should* work fine, both with and without
Two-Factor-Authentication. It also supports handling the *checkpoint challenge*,
issued when Instagram suspects authentication activity on your account, by
pointing the user to an URL to be opened in a browser.
Nevertheless, in :issue:`92` users report problems with logging in. To still use
Instaloader's logged-in functionality, you may use the following script to
workaround login problems by importing the session cookies from Firefox and
bypassing Instaloader's login.
.. literalinclude:: codesnippets/92_import_firefox_session.py
To use this,
#. login to Instagram in Firefox,
#. execute the snippet,
#. then, ``instaloader -l USERNAME`` should work fine.
If you do not use your default firefox profile, or your operating system has the
paths differently set up, you may have to alter the ``FIREFOXCOOKIEFILE``
variable first.
+1 -1
View File
@@ -1,7 +1,7 @@
"""Download pictures (or videos) along with their captions and other metadata from Instagram."""
__version__ = '4.2'
__version__ = '4.2.2'
try:
+2
View File
@@ -179,6 +179,8 @@ def _main(instaloader: Instaloader, targetlist: List[str],
if len(profiles) > 1:
instaloader.context.log("Downloading {} profiles: {}".format(len(profiles),
' '.join([p.username for p in profiles])))
if profiles and download_profile_pic and not instaloader.context.is_logged_in:
instaloader.context.error("Warning: Use --login to download HD version of profile pictures.")
instaloader.download_profiles(profiles,
download_profile_pic, download_posts, download_tagged, download_highlights,
download_stories, fast_update, post_filter, storyitem_filter)
+9
View File
@@ -22,6 +22,10 @@ class ProfileNotExistsException(InstaloaderException):
class ProfileHasNoPicsException(InstaloaderException):
"""
.. deprecated:: 4.2.2
Not raised anymore.
"""
pass
@@ -53,5 +57,10 @@ class ConnectionException(InstaloaderException):
pass
class PostChangedException(InstaloaderException):
""".. versionadded:: 4.2.2"""
pass
class TooManyRequestsException(ConnectionException):
pass
+83 -24
View File
@@ -11,9 +11,13 @@ import tempfile
from contextlib import contextmanager, suppress
from datetime import datetime, timezone
from functools import wraps
from hashlib import md5
from io import BytesIO
from typing import Any, Callable, Iterator, List, Optional, Set, Union
import requests
import urllib3
from .exceptions import *
from .instaloadercontext import InstaloaderContext
from .structures import Highlight, JsonExportable, Post, PostLocation, Profile, Story, StoryItem, save_structure_to_file, load_structure_from_file
@@ -46,6 +50,37 @@ def _requires_login(func: Callable) -> Callable:
return call
def _retry_on_connection_error(func: Callable) -> Callable:
"""Decorator to retry the function max_connection_attemps number of times.
Herewith-decorated functions need an ``_attempt`` keyword argument.
This is to decorate functions that do network requests that may fail. Note that
:meth:`.get_json`, :meth:`.get_iphone_json`, :meth:`.graphql_query` and :meth:`.graphql_node_list` already have
their own logic for retrying, hence functions that only use these for network access must not be decorated with this
decorator."""
@wraps(func)
def call(instaloader, *args, **kwargs):
try:
return func(instaloader, *args, **kwargs)
except (urllib3.exceptions.HTTPError, requests.exceptions.RequestException, ConnectionException) as err:
error_string = "{}({}): {}".format(func.__name__, ', '.join([repr(arg) for arg in args]), err)
if (kwargs.get('_attempt') or 1) == instaloader.context.max_connection_attempts:
raise ConnectionException(error_string) from None
instaloader.context.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False)
try:
if kwargs.get('_attempt'):
kwargs['_attempt'] += 1
else:
kwargs['_attempt'] = 2
instaloader.context.do_sleep()
return call(instaloader, *args, **kwargs)
except KeyboardInterrupt:
instaloader.context.error("[skipped by user]", repeat_at_end=False)
raise ConnectionException(error_string) from None
return call
class _ArbitraryItemFormatter(string.Formatter):
def __init__(self, item: Any):
self._item = item
@@ -174,7 +209,9 @@ class Instaloader:
def __exit__(self, *args):
self.close()
def download_pic(self, filename: str, url: str, mtime: datetime, filename_suffix: Optional[str] = None) -> bool:
@_retry_on_connection_error
def download_pic(self, filename: str, url: str, mtime: datetime,
filename_suffix: Optional[str] = None, _attempt: int = 1) -> bool:
"""Downloads and saves picture with given url under given directory with given timestamp.
Returns true, if file was actually downloaded, i.e. updated."""
urlmatch = re.search('\\.[a-z0-9]*\\?', url)
@@ -254,8 +291,7 @@ class Instaloader:
return None
else:
def get_filename(index):
return filename if index == 0 else (filename[:-4] + '_old_' +
(str(0) if index < 10 else str()) + str(index) + filename[-4:])
return filename if index == 0 else '{0}_old_{2:02}{1}'.format(*os.path.splitext(filename), index)
i = 0
while os.path.isfile(get_filename(i)):
@@ -285,30 +321,39 @@ class Instaloader:
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
self.context.log('geo', end=' ', flush=True)
def download_profilepic(self, profile: Profile) -> None:
@_retry_on_connection_error
def download_profilepic(self, profile: Profile, _attempt: int = 1) -> None:
"""Downloads and saves profile pic."""
def _epoch_to_string(epoch: datetime) -> str:
return epoch.strftime('%Y-%m-%d_%H-%M-%S')
return epoch.strftime('%Y-%m-%d_%H-%M-%S_UTC')
profile_pic_url = profile.profile_pic_url
with self.context.get_anonymous_session() as anonymous_session:
date_object = datetime.strptime(anonymous_session.head(profile_pic_url).headers["Last-Modified"],
'%a, %d %b %Y %H:%M:%S GMT')
profile_pic_response = self.context.get_raw(profile.profile_pic_url)
if 'Last-Modified' in profile_pic_response.headers:
date_object = datetime.strptime(profile_pic_response.headers["Last-Modified"], '%a, %d %b %Y %H:%M:%S GMT')
profile_pic_bytes = None
profile_pic_identifier = _epoch_to_string(date_object)
else:
date_object = None
profile_pic_bytes = profile_pic_response.content
profile_pic_identifier = md5(profile_pic_bytes).hexdigest()[:16]
profile_pic_extension = 'jpg'
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
format_string_contains_key(self.dirname_pattern, 'target'))):
filename = '{0}/{1}_UTC_profile_pic.{2}'.format(self.dirname_pattern.format(profile=profile.username.lower(),
target=profile.username.lower()),
_epoch_to_string(date_object), profile_pic_extension)
filename = '{0}/{1}_profile_pic.{2}'.format(self.dirname_pattern.format(profile=profile.username.lower(),
target=profile.username.lower()),
profile_pic_identifier, profile_pic_extension)
else:
filename = '{0}/{1}_{2}_UTC_profile_pic.{3}'.format(self.dirname_pattern.format(), profile.username.lower(),
_epoch_to_string(date_object), profile_pic_extension)
if os.path.isfile(filename):
filename = '{0}/{1}_{2}_profile_pic.{3}'.format(self.dirname_pattern.format(), profile.username.lower(),
profile_pic_identifier, profile_pic_extension)
content_length = profile_pic_response.headers.get('Content-Length', None)
if os.path.isfile(filename) and (not self.context.is_logged_in or
content_length is not None and os.path.getsize(filename) >= int(content_length)):
self.context.log(filename + ' already exists')
return None
self.context.get_and_write_raw(profile_pic_url, filename)
os.utime(filename, (datetime.now().timestamp(), date_object.timestamp()))
self.context.write_raw(profile_pic_bytes if profile_pic_bytes else profile_pic_response, filename)
if date_object:
os.utime(filename, (datetime.now().timestamp(), date_object.timestamp()))
self.context.log('') # log output of _get_and_write_raw() does not produce \n
@_requires_login
@@ -890,6 +935,11 @@ class Instaloader:
with error_handler(profile.username):
profile_name = profile.username
# Download profile picture
if profile_pic:
with self.context.error_catcher('Download profile picture of {}'.format(profile_name)):
self.download_profilepic(profile)
# Save metadata as JSON if desired.
if self.save_metadata:
json_filename = '{0}/{1}_{2}'.format(self.dirname_pattern.format(profile=profile_name,
@@ -897,11 +947,6 @@ class Instaloader:
profile_name, profile.userid)
self.save_metadata_json(json_filename, profile)
# Download profile picture
if profile_pic:
with self.context.error_catcher('Download profile picture of {}'.format(profile_name)):
self.download_profilepic(profile)
# Catch some errors
if profile.is_private and (tagged or highlights or posts):
if not self.context.is_logged_in:
@@ -931,8 +976,22 @@ class Instaloader:
self.context.log('<skipped>')
continue
with self.context.error_catcher("Download {} of {}".format(post, profile_name)):
downloaded = self.download_post(post, target=profile_name)
if fast_update and not downloaded:
# The PostChangedException gets raised if the Post's id/shortcode changed while obtaining
# additional metadata. This is most likely the case if a HTTP redirect takes place while
# resolving the shortcode URL.
# The `post_changed` variable keeps the fast-update functionality alive: A Post which is
# obained after a redirect has probably already been downloaded as a previous Post of the
# same Profile.
# Observed in issue #225: https://github.com/instaloader/instaloader/issues/225
post_changed = False
while True:
try:
downloaded = self.download_post(post, target=profile_name)
break
except PostChangedException:
post_changed = True
continue
if fast_update and not downloaded and not post_changed:
break
if stories and profiles:
+59 -35
View File
@@ -10,11 +10,10 @@ import time
import urllib.parse
from contextlib import contextmanager
from datetime import datetime, timedelta
from typing import Any, Callable, Dict, Iterator, Optional
from typing import Any, Callable, Dict, Iterator, Optional, Union
import requests
import requests.utils
import urllib3
from .exceptions import *
@@ -195,7 +194,7 @@ class InstaloaderContext:
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
self._sleep()
self.do_sleep()
login = session.post('https://www.instagram.com/accounts/login/ajax/',
data={'password': passwd, 'username': user}, allow_redirects=True)
try:
@@ -261,7 +260,7 @@ class InstaloaderContext:
self.username = user
self.two_factor_auth_pending = None
def _sleep(self):
def do_sleep(self):
"""Sleep a short time if self.sleep is set. Called before each request to instagram.com."""
if self.sleep:
time.sleep(min(random.expovariate(0.7), 5.0))
@@ -304,7 +303,7 @@ class InstaloaderContext:
self.query_timestamps = [time.monotonic()]
sess = session if session else self._session
try:
self._sleep()
self.do_sleep()
resp = sess.get('https://{0}/{1}'.format(host, path), params=params, allow_redirects=False)
while resp.is_redirect:
redirect_url = resp.headers['location']
@@ -355,7 +354,7 @@ class InstaloaderContext:
self.log('The request will be retried in {} seconds, at {:%H:%M}.'
.format(waittime, datetime.now() + timedelta(seconds=waittime)))
time.sleep(waittime)
self._sleep()
self.do_sleep()
return self.get_json(path=path, params=params, host=host, session=sess, _attempt=_attempt + 1)
except KeyboardInterrupt:
self.error("[skipped by user]", repeat_at_end=False)
@@ -429,39 +428,64 @@ class InstaloaderContext:
data = _query()
yield from (edge['node'] for edge in data['edges'])
def get_and_write_raw(self, url: str, filename: str, _attempt=1) -> None:
"""Downloads raw data.
def get_iphone_json(self, path: str, params: Dict[str, Any]) -> Dict[str, Any]:
"""JSON request to ``i.instagram.com``.
:param path: URL, relative to ``i.instagram.com/``
:param params: GET parameters
:return: Decoded response dictionary
:raises QueryReturnedBadRequestException: When the server responds with a 400.
:raises QueryReturnedNotFoundException: When the server responds with a 404.
:raises ConnectionException: When query repeatedly failed.
.. versionadded:: 4.2.1"""
with copy_session(self._session) as tempsession:
tempsession.headers['User-Agent'] = 'Instagram 10.3.2 (iPhone7,2; iPhone OS 9_3_3; en_US; en-US; ' \
'scale=2.00; 750x1334) AppleWebKit/420+'
for header in ['Host', 'Origin', 'X-Instagram-AJAX', 'X-Requested-With']:
tempsession.headers.pop(header, None)
return self.get_json(path, params, 'i.instagram.com', tempsession)
def write_raw(self, resp: Union[bytes, requests.Response], filename: str) -> None:
"""Write raw response data into a file.
.. versionadded:: 4.2.1"""
self.log(filename, end=' ', flush=True)
with open(filename, 'wb') as file:
if isinstance(resp, requests.Response):
shutil.copyfileobj(resp.raw, file)
else:
file.write(resp)
def get_raw(self, url: str, _attempt=1) -> requests.Response:
"""Downloads a file anonymously.
:raises QueryReturnedNotFoundException: When the server responds with a 404.
:raises QueryReturnedForbiddenException: When the server responds with a 403.
:raises ConnectionException: When download failed.
.. versionadded:: 4.2.1"""
with self.get_anonymous_session() as anonymous_session:
resp = anonymous_session.get(url, stream=True)
if resp.status_code == 200:
resp.raw.decode_content = True
return resp
else:
if resp.status_code == 403:
# suspected invalid URL signature
raise QueryReturnedForbiddenException("403 when accessing {}.".format(url))
if resp.status_code == 404:
# 404 not worth retrying.
raise QueryReturnedNotFoundException("404 when accessing {}.".format(url))
raise ConnectionException("HTTP error code {}.".format(resp.status_code))
def get_and_write_raw(self, url: str, filename: str) -> None:
"""Downloads and writes anonymously-requested raw data into a file.
:raises QueryReturnedNotFoundException: When the server responds with a 404.
:raises QueryReturnedForbiddenException: When the server responds with a 403.
:raises ConnectionException: When download repeatedly failed."""
try:
with self.get_anonymous_session() as anonymous_session:
resp = anonymous_session.get(url, stream=True)
if resp.status_code == 200:
self.log(filename, end=' ', flush=True)
with open(filename, 'wb') as file:
resp.raw.decode_content = True
shutil.copyfileobj(resp.raw, file)
else:
if resp.status_code == 403:
# suspected invalid URL signature
raise QueryReturnedForbiddenException("403 when accessing {}.".format(url))
if resp.status_code == 404:
# 404 not worth retrying.
raise QueryReturnedNotFoundException("404 when accessing {}.".format(url))
raise ConnectionException("HTTP error code {}.".format(resp.status_code))
except (urllib3.exceptions.HTTPError, requests.exceptions.RequestException, ConnectionException) as err:
error_string = "URL {}: {}".format(url, err)
if _attempt == self.max_connection_attempts:
raise ConnectionException(error_string) from err
self.error(error_string + " [retrying; skip with ^C]", repeat_at_end=False)
try:
self._sleep()
self.get_and_write_raw(url, filename, _attempt + 1)
except KeyboardInterrupt:
self.error("[skipped by user]", repeat_at_end=False)
raise ConnectionException(error_string) from err
self.write_raw(self.get_raw(url), filename)
@property
def root_rhx_gis(self) -> Optional[str]:
+41 -14
View File
@@ -128,6 +128,9 @@ class Post:
pic_json = self._context.get_json("p/{0}/".format(self.shortcode), params={})
self._full_metadata_dict = pic_json['entry_data']['PostPage'][0]['graphql']['shortcode_media']
self._rhx_gis_str = pic_json['rhx_gis']
if self.shortcode != self._full_metadata_dict['shortcode']:
self._node.update(self._full_metadata_dict)
raise PostChangedException
@property
def _full_metadata(self) -> Dict[str, Any]:
@@ -373,6 +376,10 @@ class Profile:
self._has_public_story = None
self._node = node
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
def from_username(cls, context: InstaloaderContext, username: str):
@@ -392,28 +399,27 @@ class Profile:
@classmethod
def from_id(cls, context: InstaloaderContext, profile_id: int):
"""Create a Profile instance from a given userid. If possible, use :meth:`Profile.from_username`
or constructor directly rather than this method, since it does many requests.
or constructor directly rather than this method, since it requires more requests.
:param context: :attr:`Instaloader.context`
:param profile_id: userid
:raises: :class:`ProfileNotExistsException`, :class:`ProfileHasNoPicsException`
:raises: :class:`ProfileNotExistsException`
"""
if profile_id in context.profile_id_cache:
return context.profile_id_cache[profile_id]
data = context.graphql_query("472f257a40c653c64c666ce877d59d2b",
{'id': str(profile_id), 'first': 1},
data = context.graphql_query('7c16654f22c819fb63d1183034a5162f',
{'user_id': str(profile_id),
'include_chaining': False,
'include_reel': True,
'include_suggested_users': False,
'include_logged_out_extras': False,
'include_highlight_reels': False},
rhx_gis=context.root_rhx_gis)['data']['user']
if data:
data = data["edge_owner_to_timeline_media"]
profile = cls(context, data['reel']['owner'])
else:
raise ProfileNotExistsException("No profile found, the user may have blocked you (ID: " +
str(profile_id) + ").")
if not data['edges']:
if data['count'] == 0:
raise ProfileHasNoPicsException("Profile with ID {0}: no pics found.".format(str(profile_id)))
else:
raise LoginRequiredException("Login required to determine username (ID: " + str(profile_id) + ").")
profile = Post(context, data['edges'][0]['node']).owner_profile
context.profile_id_cache[profile_id] = profile
return profile
@@ -423,6 +429,8 @@ class Profile:
json_node.pop('edge_media_collections', None)
json_node.pop('edge_owner_to_timeline_media', None)
json_node.pop('edge_saved_media', None)
if self._iphone_struct_:
json_node['iphone_struct'] = self._iphone_struct_
return json_node
def _obtain_metadata(self):
@@ -447,6 +455,15 @@ class Profile:
d = d[key]
return d
@property
def _iphone_struct(self) -> Dict[str, Any]:
if not self._context.is_logged_in:
raise LoginRequiredException("--login required to access iPhone profile info endpoint.")
if not self._iphone_struct_:
data = self._context.get_iphone_json(path='api/v1/users/{}/info/'.format(self.userid), params={})
self._iphone_struct_ = data['user']
return self._iphone_struct_
@property
def userid(self) -> int:
"""User ID"""
@@ -563,10 +580,20 @@ class Profile:
@property
def profile_pic_url(self) -> str:
"""Return URL of profile picture
"""Return URL of profile picture. If logged in, the HD version is returned, otherwise a lower-quality version.
.. versionadded:: 4.0.3"""
return self._metadata("profile_pic_url_hd")
.. versionadded:: 4.0.3
.. versionchanged:: 4.2.1
Require being logged in for HD version (as required by Instagram)."""
if self._context.is_logged_in:
try:
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")
else:
return self._metadata("profile_pic_url_hd")
def get_profile_pic_url(self) -> str:
""".. deprecated:: 4.0.3
+15 -5
View File
@@ -17,6 +17,9 @@ OWN_USERNAME = "aandergr"
NORMAL_MAX_COUNT = 2
PAGING_MAX_COUNT = 15
PRIVATE_PROFILE = "aandergr"
PRIVATE_PROFILE_ID = 1706625676
EMPTY_PROFILE = "not_public"
EMPTY_PROFILE_ID = 1928659031
# Preserve query timestamps (rate control) between tests to not get rate limited
instaloadercontext_query_timestamps = list()
@@ -83,10 +86,18 @@ class TestInstaloaderAnonymously(unittest.TestCase):
self.assertEqual(PUBLIC_PROFILE_ID,
instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).userid)
def test_get_username_by_id(self):
def test_get_username_by_id_private(self):
self.assertEqual(PRIVATE_PROFILE.lower(),
instaloader.Profile.from_id(self.L.context, PRIVATE_PROFILE_ID).username)
def test_get_username_by_id_public(self):
self.assertEqual(PUBLIC_PROFILE.lower(),
instaloader.Profile.from_id(self.L.context, PUBLIC_PROFILE_ID).username)
def test_get_username_by_id_empty(self):
self.assertEqual(EMPTY_PROFILE.lower(),
instaloader.Profile.from_id(self.L.context, EMPTY_PROFILE_ID).username)
def test_post_from_mediaid(self):
for post in instaloader.Profile.from_username(self.L.context, PUBLIC_PROFILE).get_posts():
post2 = instaloader.Post.from_mediaid(self.L.context, post.mediaid)
@@ -130,6 +141,9 @@ class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
def test_private_profile_paging(self):
self.post_paging_test(instaloader.Profile.from_username(self.L.context, PRIVATE_PROFILE).get_posts())
def test_profile_pic_download(self):
self.L.download_profile(PUBLIC_PROFILE, profile_pic_only=True)
def test_feed_download(self):
self.L.download_feed_posts(NORMAL_MAX_COUNT)
@@ -166,10 +180,6 @@ class TestInstaloaderLoggedIn(TestInstaloaderAnonymously):
for f in profile.get_followers():
print(f.username)
def test_get_username_by_id(self):
self.assertEqual(PUBLIC_PROFILE.lower(),
instaloader.Profile.from_id(self.L.context, PUBLIC_PROFILE_ID).username)
def test_get_likes(self):
for post in instaloader.Profile.from_username(self.L.context, OWN_USERNAME).get_posts():
for like in post.get_likes():