mirror of
https://github.com/instaloader/instaloader.git
synced 2026-10-28 20:21:58 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a3d763cf3 | ||
|
|
e2d42807a2 | ||
|
|
d07bd6a096 | ||
|
|
0462d5c34a | ||
|
|
3cc29a4ceb | ||
|
|
ffb0aea5eb | ||
|
|
84bb60c155 | ||
|
|
7857909886 | ||
|
|
2206a7e5d7 | ||
|
|
f1b5e9cdf1 | ||
|
|
8debcbb7ab | ||
|
|
e97fd8a27c | ||
|
|
7f9d1618ad | ||
|
|
6a42906697 | ||
|
|
4bb32e3c5a | ||
|
|
5e232fd4f5 | ||
|
|
da0dcb106c | ||
|
|
8649a10113 | ||
|
|
2d37e1dca2 | ||
|
|
37cbc5d72d | ||
|
|
853e860363 | ||
|
|
90cfeaf471 | ||
|
|
5a24064012 | ||
|
|
0704602e50 | ||
|
|
218d88fc6d | ||
|
|
385c6c8a35 | ||
|
|
c0e5d0475b | ||
|
|
fbd7df2a90 | ||
|
|
569808f7a4 | ||
|
|
b808843efe | ||
|
|
f66cdd3e07 | ||
|
|
83dc5f749d | ||
|
|
41de9e5a7c | ||
|
|
d938f3e2f5 | ||
|
|
374d9d0d81 | ||
|
|
3cbee1b318 | ||
|
|
dbc91cbeb1 | ||
|
|
5a42a7537f | ||
|
|
d8d268f861 | ||
|
|
463ada519e | ||
|
|
604b107586 |
@@ -0,0 +1,31 @@
|
|||||||
|
name: "Documentation"
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docs:
|
||||||
|
name: "Documentation"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: "Checkout Instaloader Repository"
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # needed for building docs
|
||||||
|
- name: "Setup Python"
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: 3.8
|
||||||
|
- name: "Install Dependencies"
|
||||||
|
run: |
|
||||||
|
python -m pip install pipenv==2023.2.4
|
||||||
|
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
|
||||||
|
- name: "Build Documentation"
|
||||||
|
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run make -C docs html SPHINXOPTS="-W -n"
|
||||||
|
- name: "Deploy Documentation"
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||||
|
uses: JamesIves/github-pages-deploy-action@v4.2.5
|
||||||
|
with:
|
||||||
|
branch: master
|
||||||
|
repository-name: instaloader/instaloader.github.io
|
||||||
|
folder: docs/_build/html
|
||||||
|
token: ${{ secrets.PAGES_DEPLOYMENT_TOKEN }}
|
||||||
@@ -7,25 +7,21 @@ jobs:
|
|||||||
name: PyLint and MyPy
|
name: PyLint and MyPy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.8", "3.9", "3.10"]
|
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Instaloader Repository
|
- name: Checkout Instaloader Repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
with:
|
|
||||||
fetch-depth: 0 # needed for building docs
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install pipenv==2021.5.29
|
python -m pip install pipenv==2023.2.4
|
||||||
pipenv sync --dev
|
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
|
||||||
- name: PyLint
|
- name: PyLint
|
||||||
run: pipenv run pylint instaloader
|
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run pylint instaloader
|
||||||
- name: MyPy
|
- name: MyPy
|
||||||
run: pipenv run mypy -m instaloader
|
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run mypy -m instaloader
|
||||||
- name: Build Documentation
|
|
||||||
if: matrix.python-version == '3.8'
|
|
||||||
run: pipenv run make -C docs html SPHINXOPTS="-W -n"
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: "Publish Packages"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- v*
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: "Publish Package"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: "Checkout Repository"
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: "Setup Python"
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: 3.8
|
||||||
|
- name: "Get the tagged version"
|
||||||
|
id: get_version
|
||||||
|
env:
|
||||||
|
GITHUB_REF: ${{ github.ref }}
|
||||||
|
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\/v/}
|
||||||
|
shell: bash
|
||||||
|
- name: "Generate Python Package"
|
||||||
|
run: python setup.py sdist
|
||||||
|
- name: "Publish Distribution to PyPI"
|
||||||
|
uses: pypa/gh-action-pypi-publish@master
|
||||||
|
with:
|
||||||
|
password: ${{ secrets.PYPI_PASSWORD }}
|
||||||
|
- name: "Publish Distribution to AUR"
|
||||||
|
run: deploy/arch/deploy.sh $VERSION_TAG
|
||||||
|
env:
|
||||||
|
VERSION_TAG: v${{ steps.get_version.outputs.VERSION }}
|
||||||
|
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||||
|
AUR_IV: ${{ secrets.AUR_IV }}
|
||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
stale-issue-message: 'There has been no activity on this question for an extended period of time. This issue will be closed after further 14 days of inactivity.'
|
stale-issue-message: 'There has been no activity on this question for an extended period of time. This issue will be closed after further 14 days of inactivity.'
|
||||||
stale-issue-label: 'stale'
|
stale-issue-label: 'stale'
|
||||||
exempt-issue-labels: 'leave open'
|
exempt-issue-labels: 'leave open'
|
||||||
days-before-stale: 15
|
days-before-stale: 21
|
||||||
days-before-close: -1
|
days-before-close: -1
|
||||||
remove-stale-when-updated: false
|
remove-stale-when-updated: false
|
||||||
- uses: actions/stale@v1
|
- uses: actions/stale@v1
|
||||||
@@ -30,5 +30,5 @@ jobs:
|
|||||||
stale-pr-label: 'stale'
|
stale-pr-label: 'stale'
|
||||||
exempt-issue-label: 'leave open'
|
exempt-issue-label: 'leave open'
|
||||||
exempt-pr-label: 'leave open'
|
exempt-pr-label: 'leave open'
|
||||||
days-before-stale: 135
|
days-before-stale: 189
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
|
|||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
dist: xenial
|
|
||||||
language: python
|
|
||||||
python:
|
|
||||||
- "3.6"
|
|
||||||
- "3.7"
|
|
||||||
install:
|
|
||||||
- pipenv sync --dev
|
|
||||||
script:
|
|
||||||
- pylint instaloader
|
|
||||||
- mypy -m instaloader
|
|
||||||
- make -C docs html SPHINXOPTS="-W -n"
|
|
||||||
addons:
|
|
||||||
ssh_known_hosts: aur.archlinux.org
|
|
||||||
deploy:
|
|
||||||
- provider: pypi
|
|
||||||
user: aandergr
|
|
||||||
on:
|
|
||||||
tags: true
|
|
||||||
python: 3.6
|
|
||||||
password:
|
|
||||||
secure: KBsATtXNnEzqr0X2ALdyx2zaYj2EUfK8YjP4jEOilBELpJ4evHYXVgRkn9CKXGsD7LKbWmR3gz2rr8NZ6U42pHw0fpQHizz91MpJSKbNY7RZZh4UVWMDh81izeglchWAF+m3Wdpxpo/GBDbrZIbRQFhPgB+kjSzJo3uiCS+yVXtGG+6csJHzd0WkJjsVHz+XSdxyOiLy/qFF3nmY6ZrVrgw6WbX2YLbx9je6DxK3YRrnBqgMgH7Ui2xWWbgJCk3KKpQTMiMKnTa9mdeMJAd26YMCz1gG/X7ck6VNly/6bs5+Zq7/bU0dSolhPBhNthyccYdS0IeGPdPF3YqatblJ4fLGTfHn8oJs3/T11yIW9pvOT3UftEynPOUX+en2Qnb/3WfgwUfVts8i8DAYgEFLGEzbF5XMGy4gwAD/bZ0JW0pmgAG2PZsdOokAt+ydlNj/uxq1GFW2doZbul6k4t03p1mik6itYscvuhKoEm8Yw6wr1P9aDvMR6FFpSu4gDt2XrKmRmWkSXxpNeCfZujTjyAnISxLVQGSY7BG5K0Z2HbDtqqNz9nj4WgNGzZmngiRofCevWR+vPIDUDW7NCX03csfcFS9RRllBO/BrL7NVUV+Qp9dfTQYp+8SqYLWgHtDoXdgyMREjJaAmbx2u/TYTY8bADZ5F3vGUpv2j5hP3NTU=
|
|
||||||
- provider: pages
|
|
||||||
skip-cleanup: true
|
|
||||||
github-token: $GITHUB_TOKEN
|
|
||||||
local-dir: docs/_build/html
|
|
||||||
repo: instaloader/instaloader.github.io
|
|
||||||
target-branch: master
|
|
||||||
on:
|
|
||||||
branch: master
|
|
||||||
python: 3.6
|
|
||||||
- provider: script
|
|
||||||
script: deploy/arch/deploy.sh $TRAVIS_TAG
|
|
||||||
on:
|
|
||||||
tags: true
|
|
||||||
python: 3.6
|
|
||||||
@@ -14,6 +14,10 @@ psutil = "*"
|
|||||||
typing-extensions = "*"
|
typing-extensions = "*"
|
||||||
types-requests = "*"
|
types-requests = "*"
|
||||||
typed-ast = "*"
|
typed-ast = "*"
|
||||||
|
jinja2 = "~=3.0.1"
|
||||||
|
wrapt = "*" # Needed for Python<3.11
|
||||||
|
tomli = "*" # Needed for Python<3.11
|
||||||
|
dill = "*" # Needed for Python<3.11
|
||||||
|
|
||||||
[packages]
|
[packages]
|
||||||
requests = "*"
|
requests = "*"
|
||||||
|
|||||||
Generated
+570
-267
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,9 @@ Supporters
|
|||||||
|
|
||||||
.. current-sponsors-start
|
.. current-sponsors-start
|
||||||
|
|
||||||
|
| Instaloader is proudly sponsored by
|
||||||
|
| `@socialmethod <https://github.com/socialmethod>`__
|
||||||
|
|
||||||
See `Alex' GitHub Sponsors <https://github.com/sponsors/aandergr>`__ page for
|
See `Alex' GitHub Sponsors <https://github.com/sponsors/aandergr>`__ page for
|
||||||
how you can sponsor the development of Instaloader!
|
how you can sponsor the development of Instaloader!
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ pkgbase = instaloader
|
|||||||
arch = any
|
arch = any
|
||||||
license = MIT
|
license = MIT
|
||||||
makedepends = python-setuptools
|
makedepends = python-setuptools
|
||||||
depends = python>=3.6
|
depends = python>=3.8
|
||||||
depends = python-requests>=2.4
|
depends = python-requests>=2.4
|
||||||
options = !emptydirs
|
options = !emptydirs
|
||||||
source = instaloader-{{version}}.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v{{version}}
|
source = instaloader-{{version}}.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v{{version}}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ arch=('any')
|
|||||||
url="https://instaloader.github.io/"
|
url="https://instaloader.github.io/"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
groups=()
|
groups=()
|
||||||
depends=('python>=3.6' 'python-requests>=2.4')
|
depends=('python>=3.8' 'python-requests>=2.4')
|
||||||
makedepends=('python-setuptools')
|
makedepends=('python-setuptools')
|
||||||
options=('!emptydirs')
|
options=('!emptydirs')
|
||||||
source=($pkgname-$pkgver.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v$pkgver)
|
source=($pkgname-$pkgver.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v$pkgver)
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ echo $VERSION | grep -qe "[abc]" && exit 0
|
|||||||
cd $(dirname $0)
|
cd $(dirname $0)
|
||||||
|
|
||||||
# decrypt and add ssh key
|
# decrypt and add ssh key
|
||||||
openssl aes-256-cbc -K $encrypted_354637631c28_key -iv $encrypted_354637631c28_iv -in id_rsa_AUR.enc -out /tmp/AUR_openssh -d
|
openssl aes-256-cbc -K $AUR_KEY -iv $AUR_IV -in id_rsa_AUR.enc -out /tmp/AUR_openssh -d
|
||||||
eval "$(ssh-agent -s)"
|
eval "$(ssh-agent -s)"
|
||||||
chmod 600 /tmp/AUR_openssh
|
chmod 600 /tmp/AUR_openssh
|
||||||
ssh-add /tmp/AUR_openssh
|
ssh-add /tmp/AUR_openssh
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
ssh-keyscan -t rsa aur.archlinux.org >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
# clone and modify AUR repo
|
# clone and modify AUR repo
|
||||||
git clone --depth 1 ssh://aur@aur.archlinux.org/instaloader.git
|
git clone --depth 1 ssh://aur@aur.archlinux.org/instaloader.git
|
||||||
|
|||||||
Binary file not shown.
@@ -44,7 +44,7 @@ with open('__main__.py', 'w+') as f:
|
|||||||
f.writelines(lines)
|
f.writelines(lines)
|
||||||
|
|
||||||
# install dependencies and invoke PyInstaller
|
# install dependencies and invoke PyInstaller
|
||||||
commands = ["pip install pipenv==2021.5.29",
|
commands = ["pip install pipenv==2023.2.4",
|
||||||
f"pipenv --python {sys.version_info.major}.{sys.version_info.minor} sync --dev",
|
f"pipenv --python {sys.version_info.major}.{sys.version_info.minor} sync --dev",
|
||||||
"pipenv run pyinstaller --log-level=DEBUG instaloader.spec"]
|
"pipenv run pyinstaller --log-level=DEBUG instaloader.spec"]
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -239,6 +239,13 @@ How to Download
|
|||||||
|
|
||||||
.. versionadded:: 4.8
|
.. versionadded:: 4.8
|
||||||
|
|
||||||
|
.. option:: --sanitize-paths
|
||||||
|
|
||||||
|
Force sanitization of paths so that the resulting file and directory names
|
||||||
|
are valid on both Windows and Unix.
|
||||||
|
|
||||||
|
.. versionadded:: 4.9
|
||||||
|
|
||||||
.. option:: --resume-prefix prefix
|
.. option:: --resume-prefix prefix
|
||||||
|
|
||||||
For many targets, Instaloader is capable of resuming a previously-aborted
|
For many targets, Instaloader is capable of resuming a previously-aborted
|
||||||
@@ -251,7 +258,8 @@ How to Download
|
|||||||
- Profile posts,
|
- Profile posts,
|
||||||
- Profile IGTV posts (:option:`--igtv`),
|
- Profile IGTV posts (:option:`--igtv`),
|
||||||
- Profile tagged posts (:option:`--tagged`),
|
- Profile tagged posts (:option:`--tagged`),
|
||||||
- Saved posts (``:saved``).
|
- Saved posts (``:saved``),
|
||||||
|
- Hashtags.
|
||||||
|
|
||||||
This feature is enabled by default for targets where it is supported;
|
This feature is enabled by default for targets where it is supported;
|
||||||
:option:`--resume-prefix` only changes the name of the iterator files.
|
:option:`--resume-prefix` only changes the name of the iterator files.
|
||||||
@@ -321,12 +329,13 @@ Miscellaneous Options
|
|||||||
Read arguments from file `args.txt`, a shortcut to provide arguments from
|
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
|
file rather than command-line. This provides a convenient way to hide login
|
||||||
info from CLI, and can also be used to simplify management of long arguments.
|
info from CLI, and can also be used to simplify management of long arguments.
|
||||||
|
You can provide more than one file at once, e.g.: ``+args1.txt +args2.txt``.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
Text file should separate arguments with line breaks.
|
Text file should separate arguments with line breaks.
|
||||||
|
|
||||||
args.txt example::
|
`args.txt` example::
|
||||||
|
|
||||||
--login=MYUSERNAME
|
--login=MYUSERNAME
|
||||||
--password=MYPASSWORD
|
--password=MYPASSWORD
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ autodoc_default_flags = ['show-inheritance', 'members', 'undoc-members']
|
|||||||
autodoc_member_order = 'bysource'
|
autodoc_member_order = 'bysource'
|
||||||
|
|
||||||
intersphinx_mapping = {'python': ('https://docs.python.org/3', None),
|
intersphinx_mapping = {'python': ('https://docs.python.org/3', None),
|
||||||
'requests': ('https://requests.kennethreitz.org/en/master/', None)}
|
'requests': ('https://requests.readthedocs.io/en/latest/', None)}
|
||||||
|
|
||||||
nitpick_ignore = [('py:class', 'typing.Tuple')]
|
nitpick_ignore = [('py:class', 'typing.Tuple')]
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Install Instaloader
|
|||||||
To **install Instaloader**,
|
To **install Instaloader**,
|
||||||
|
|
||||||
#. Ensure that you have `Python <https://www.python.org/>`__, at least
|
#. Ensure that you have `Python <https://www.python.org/>`__, at least
|
||||||
version 3.6, and `pip <https://pypi.python.org/pypi/pip>`__
|
version 3.8, and `pip <https://pypi.python.org/pypi/pip>`__
|
||||||
installed.
|
installed.
|
||||||
|
|
||||||
#. Then, install Instaloader using::
|
#. Then, install Instaloader using::
|
||||||
|
|||||||
@@ -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.9b2'
|
__version__ = '4.9.6'
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ def filterstr_to_filterfunc(filter_str: str, item_type: type):
|
|||||||
|
|
||||||
class TransformFilterAst(ast.NodeTransformer):
|
class TransformFilterAst(ast.NodeTransformer):
|
||||||
def visit_Name(self, node: ast.Name):
|
def visit_Name(self, node: ast.Name):
|
||||||
# pylint:disable=no-self-use
|
|
||||||
if not isinstance(node.ctx, ast.Load):
|
if not isinstance(node.ctx, ast.Load):
|
||||||
raise InvalidArgumentException("Invalid filter: Modifying variables ({}) not allowed.".format(node.id))
|
raise InvalidArgumentException("Invalid filter: Modifying variables ({}) not allowed.".format(node.id))
|
||||||
if node.id == "datetime":
|
if node.id == "datetime":
|
||||||
@@ -383,6 +382,9 @@ def main():
|
|||||||
g_how.add_argument('--resume-prefix', metavar='PREFIX',
|
g_how.add_argument('--resume-prefix', metavar='PREFIX',
|
||||||
help='Prefix for filenames that are used to save the information to resume an interrupted '
|
help='Prefix for filenames that are used to save the information to resume an interrupted '
|
||||||
'download.')
|
'download.')
|
||||||
|
g_how.add_argument('--sanitize-paths', action='store_true',
|
||||||
|
help='Sanitize paths so that the resulting file and directory names are valid on both '
|
||||||
|
'Windows and Unix.')
|
||||||
g_how.add_argument('--no-resume', action='store_true',
|
g_how.add_argument('--no-resume', action='store_true',
|
||||||
help='Do not resume a previously-aborted download iteration, and do not save such information '
|
help='Do not resume a previously-aborted download iteration, and do not save such information '
|
||||||
'when interrupted.')
|
'when interrupted.')
|
||||||
@@ -463,7 +465,8 @@ def main():
|
|||||||
slide=args.slide,
|
slide=args.slide,
|
||||||
fatal_status_codes=args.abort_on,
|
fatal_status_codes=args.abort_on,
|
||||||
iphone_support=not args.no_iphone,
|
iphone_support=not args.no_iphone,
|
||||||
title_pattern=args.title_pattern)
|
title_pattern=args.title_pattern,
|
||||||
|
sanitize_paths=args.sanitize_paths)
|
||||||
_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,
|
||||||
|
|||||||
+81
-40
@@ -137,24 +137,38 @@ class _ArbitraryItemFormatter(string.Formatter):
|
|||||||
|
|
||||||
|
|
||||||
class _PostPathFormatter(_ArbitraryItemFormatter):
|
class _PostPathFormatter(_ArbitraryItemFormatter):
|
||||||
|
RESERVED: set = {'CON', 'PRN', 'AUX', 'NUL',
|
||||||
|
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
||||||
|
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'}
|
||||||
|
|
||||||
|
def __init__(self, item: Any, force_windows_path: bool = False):
|
||||||
|
super().__init__(item)
|
||||||
|
self.force_windows_path = force_windows_path
|
||||||
|
|
||||||
def get_value(self, key, args, kwargs):
|
def get_value(self, key, args, kwargs):
|
||||||
ret = super().get_value(key, args, kwargs)
|
ret = super().get_value(key, args, kwargs)
|
||||||
if not isinstance(ret, str):
|
if not isinstance(ret, str):
|
||||||
return ret
|
return ret
|
||||||
return self.sanitize_path(ret)
|
return self.sanitize_path(ret, self.force_windows_path)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def sanitize_path(ret: str) -> str:
|
def sanitize_path(ret: str, force_windows_path: bool = False) -> str:
|
||||||
"""Replaces '/' with similar looking Division Slash and some other illegal filename characters on Windows."""
|
"""Replaces '/' with similar looking Division Slash and some other illegal filename characters on Windows."""
|
||||||
ret = ret.replace('/', '\u2215')
|
ret = ret.replace('/', '\u2215')
|
||||||
|
|
||||||
if ret.startswith('.'):
|
if ret.startswith('.'):
|
||||||
ret = ret.replace('.', '\u2024', 1)
|
ret = ret.replace('.', '\u2024', 1)
|
||||||
|
|
||||||
if platform.system() == 'Windows':
|
if force_windows_path or platform.system() == 'Windows':
|
||||||
ret = ret.replace(':', '\uff1a').replace('<', '\ufe64').replace('>', '\ufe65').replace('\"', '\uff02')
|
ret = ret.replace(':', '\uff1a').replace('<', '\ufe64').replace('>', '\ufe65').replace('\"', '\uff02')
|
||||||
ret = ret.replace('\\', '\ufe68').replace('|', '\uff5c').replace('?', '\ufe16').replace('*', '\uff0a')
|
ret = ret.replace('\\', '\ufe68').replace('|', '\uff5c').replace('?', '\ufe16').replace('*', '\uff0a')
|
||||||
ret = ret.replace('\n', ' ').replace('\r', ' ')
|
ret = ret.replace('\n', ' ').replace('\r', ' ')
|
||||||
|
root, ext = os.path.splitext(ret)
|
||||||
|
if root.upper() in _PostPathFormatter.RESERVED:
|
||||||
|
root += '_'
|
||||||
|
if ext == '.':
|
||||||
|
ext = '\u2024'
|
||||||
|
ret = root + ext
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
@@ -187,6 +201,7 @@ class Instaloader:
|
|||||||
:param slide: :option:`--slide`
|
:param slide: :option:`--slide`
|
||||||
:param fatal_status_codes: :option:`--abort-on`
|
:param fatal_status_codes: :option:`--abort-on`
|
||||||
:param iphone_support: not :option:`--no-iphone`
|
:param iphone_support: not :option:`--no-iphone`
|
||||||
|
:param sanitize_paths: :option:`--sanitize-paths`
|
||||||
|
|
||||||
.. attribute:: context
|
.. attribute:: context
|
||||||
|
|
||||||
@@ -206,8 +221,8 @@ class Instaloader:
|
|||||||
download_comments: bool = False,
|
download_comments: bool = False,
|
||||||
save_metadata: bool = True,
|
save_metadata: bool = True,
|
||||||
compress_json: bool = True,
|
compress_json: bool = True,
|
||||||
post_metadata_txt_pattern: str = None,
|
post_metadata_txt_pattern: Optional[str] = None,
|
||||||
storyitem_metadata_txt_pattern: str = None,
|
storyitem_metadata_txt_pattern: Optional[str] = None,
|
||||||
max_connection_attempts: int = 3,
|
max_connection_attempts: int = 3,
|
||||||
request_timeout: float = 300.0,
|
request_timeout: float = 300.0,
|
||||||
rate_controller: Optional[Callable[[InstaloaderContext], RateController]] = None,
|
rate_controller: Optional[Callable[[InstaloaderContext], RateController]] = None,
|
||||||
@@ -216,7 +231,8 @@ class Instaloader:
|
|||||||
slide: Optional[str] = None,
|
slide: Optional[str] = None,
|
||||||
fatal_status_codes: Optional[List[int]] = None,
|
fatal_status_codes: Optional[List[int]] = None,
|
||||||
iphone_support: bool = True,
|
iphone_support: bool = True,
|
||||||
title_pattern: Optional[str] = None):
|
title_pattern: Optional[str] = None,
|
||||||
|
sanitize_paths: bool = False):
|
||||||
|
|
||||||
self.context = InstaloaderContext(sleep, quiet, user_agent, max_connection_attempts,
|
self.context = InstaloaderContext(sleep, quiet, user_agent, max_connection_attempts,
|
||||||
request_timeout, rate_controller, fatal_status_codes,
|
request_timeout, rate_controller, fatal_status_codes,
|
||||||
@@ -233,6 +249,7 @@ class Instaloader:
|
|||||||
self.title_pattern = '{date_utc}_UTC_{typename}'
|
self.title_pattern = '{date_utc}_UTC_{typename}'
|
||||||
else:
|
else:
|
||||||
self.title_pattern = '{target}_{date_utc}_UTC_{typename}'
|
self.title_pattern = '{target}_{date_utc}_UTC_{typename}'
|
||||||
|
self.sanitize_paths = sanitize_paths
|
||||||
self.download_pictures = download_pictures
|
self.download_pictures = download_pictures
|
||||||
self.download_videos = download_videos
|
self.download_videos = download_videos
|
||||||
self.download_video_thumbnails = download_video_thumbnails
|
self.download_video_thumbnails = download_video_thumbnails
|
||||||
@@ -296,7 +313,8 @@ class Instaloader:
|
|||||||
check_resume_bbd=self.check_resume_bbd,
|
check_resume_bbd=self.check_resume_bbd,
|
||||||
slide=self.slide,
|
slide=self.slide,
|
||||||
fatal_status_codes=self.context.fatal_status_codes,
|
fatal_status_codes=self.context.fatal_status_codes,
|
||||||
iphone_support=self.context.iphone_support)
|
iphone_support=self.context.iphone_support,
|
||||||
|
sanitize_paths=self.sanitize_paths)
|
||||||
yield new_loader
|
yield new_loader
|
||||||
self.context.error_log.extend(new_loader.context.error_log)
|
self.context.error_log.extend(new_loader.context.error_log)
|
||||||
new_loader.context.error_log = [] # avoid double-printing of errors
|
new_loader.context.error_log = [] # avoid double-printing of errors
|
||||||
@@ -317,15 +335,25 @@ class Instaloader:
|
|||||||
filename_suffix: Optional[str] = None, _attempt: int = 1) -> bool:
|
filename_suffix: Optional[str] = None, _attempt: int = 1) -> bool:
|
||||||
"""Downloads and saves picture with given url under given directory with given timestamp.
|
"""Downloads and saves picture with given url under given directory with given timestamp.
|
||||||
Returns true, if file was actually downloaded, i.e. updated."""
|
Returns true, if file was actually downloaded, i.e. updated."""
|
||||||
urlmatch = re.search('\\.[a-z0-9]*\\?', url)
|
|
||||||
file_extension = url[-3:] if urlmatch is None else urlmatch.group(0)[1:-1]
|
|
||||||
if filename_suffix is not None:
|
if filename_suffix is not None:
|
||||||
filename += '_' + filename_suffix
|
filename += '_' + filename_suffix
|
||||||
filename += '.' + file_extension
|
urlmatch = re.search('\\.[a-z0-9]*\\?', url)
|
||||||
if os.path.isfile(filename):
|
file_extension = url[-3:] if urlmatch is None else urlmatch.group(0)[1:-1]
|
||||||
|
nominal_filename = filename + '.' + file_extension
|
||||||
|
if os.path.isfile(nominal_filename):
|
||||||
|
self.context.log(nominal_filename + ' exists', end=' ', flush=True)
|
||||||
|
return False
|
||||||
|
resp = self.context.get_raw(url)
|
||||||
|
if 'Content-Type' in resp.headers and resp.headers['Content-Type']:
|
||||||
|
header_extension = '.' + resp.headers['Content-Type'].split(';')[0].split('/')[-1]
|
||||||
|
header_extension = header_extension.lower().replace('jpeg', 'jpg')
|
||||||
|
filename += header_extension
|
||||||
|
else:
|
||||||
|
filename = nominal_filename
|
||||||
|
if filename != nominal_filename and os.path.isfile(filename):
|
||||||
self.context.log(filename + ' exists', end=' ', flush=True)
|
self.context.log(filename + ' exists', end=' ', flush=True)
|
||||||
return False
|
return False
|
||||||
self.context.get_and_write_raw(url, filename)
|
self.context.write_raw(resp, filename)
|
||||||
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
|
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -419,7 +447,7 @@ class Instaloader:
|
|||||||
"""Updates picture caption / Post metadata info"""
|
"""Updates picture caption / Post metadata info"""
|
||||||
def _elliptify(caption):
|
def _elliptify(caption):
|
||||||
pcaption = caption.replace('\n', ' ').strip()
|
pcaption = caption.replace('\n', ' ').strip()
|
||||||
return '[' + ((pcaption[:29] + u"\u2026") if len(pcaption) > 31 else pcaption) + ']'
|
return '[' + ((pcaption[:29] + "\u2026") if len(pcaption) > 31 else pcaption) + ']'
|
||||||
filename += '.txt'
|
filename += '.txt'
|
||||||
caption += '\n'
|
caption += '\n'
|
||||||
pcaption = _elliptify(caption)
|
pcaption = _elliptify(caption)
|
||||||
@@ -450,9 +478,8 @@ class Instaloader:
|
|||||||
self.context.log(pcaption, end=' ', flush=True)
|
self.context.log(pcaption, end=' ', flush=True)
|
||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
self.context.log('txt', end=' ', flush=True)
|
self.context.log('txt', end=' ', flush=True)
|
||||||
with open(filename, 'wb') as text_file:
|
with open(filename, 'w', encoding='UTF-8') as fio:
|
||||||
with BytesIO(bcaption) as bio:
|
fio.write(caption)
|
||||||
shutil.copyfileobj(cast(IO, bio), text_file)
|
|
||||||
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
|
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
|
||||||
|
|
||||||
def save_location(self, filename: str, location: PostLocation, mtime: datetime) -> None:
|
def save_location(self, filename: str, location: PostLocation, mtime: datetime) -> None:
|
||||||
@@ -506,9 +533,10 @@ class Instaloader:
|
|||||||
pic_bytes = http_response.content
|
pic_bytes = http_response.content
|
||||||
ig_filename = url.split('/')[-1].split('?')[0]
|
ig_filename = url.split('/')[-1].split('?')[0]
|
||||||
pic_data = TitlePic(owner_profile, target, name_suffix, ig_filename, date_object)
|
pic_data = TitlePic(owner_profile, target, name_suffix, ig_filename, date_object)
|
||||||
dirname = _PostPathFormatter(pic_data).format(self.dirname_pattern, target=target)
|
dirname = _PostPathFormatter(pic_data, self.sanitize_paths).format(self.dirname_pattern, target=target)
|
||||||
filename_template = os.path.join(dirname,
|
filename_template = os.path.join(
|
||||||
_PostPathFormatter(pic_data).format(self.title_pattern, target=target))
|
dirname,
|
||||||
|
_PostPathFormatter(pic_data, self.sanitize_paths).format(self.title_pattern, target=target))
|
||||||
filename = self.__prepare_filename(filename_template, lambda: url) + ".jpg"
|
filename = self.__prepare_filename(filename_template, lambda: url) + ".jpg"
|
||||||
content_length = http_response.headers.get('Content-Length', None)
|
content_length = http_response.headers.get('Content-Length', None)
|
||||||
if os.path.isfile(filename) and (not self.context.is_logged_in or
|
if os.path.isfile(filename) and (not self.context.is_logged_in or
|
||||||
@@ -534,7 +562,7 @@ class Instaloader:
|
|||||||
if latest_stamps is None:
|
if latest_stamps is None:
|
||||||
self.download_profilepic(profile)
|
self.download_profilepic(profile)
|
||||||
return
|
return
|
||||||
profile_pic_basename = profile.profile_pic_url.split('/')[-1].split('?')[0]
|
profile_pic_basename = profile.profile_pic_url_no_iphone.split('/')[-1].split('?')[0]
|
||||||
saved_basename = latest_stamps.get_profile_pic(profile.username)
|
saved_basename = latest_stamps.get_profile_pic(profile.username)
|
||||||
if saved_basename == profile_pic_basename:
|
if saved_basename == profile_pic_basename:
|
||||||
return
|
return
|
||||||
@@ -633,7 +661,7 @@ class Instaloader:
|
|||||||
"""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.
|
||||||
|
|
||||||
.. versionadded:: 4.1"""
|
.. versionadded:: 4.1"""
|
||||||
return _PostPathFormatter(item).format(self.filename_pattern, target=target)
|
return _PostPathFormatter(item, self.sanitize_paths).format(self.filename_pattern, target=target)
|
||||||
|
|
||||||
def download_post(self, post: Post, target: Union[str, Path]) -> bool:
|
def download_post(self, post: Post, target: Union[str, Path]) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -665,7 +693,7 @@ class Instaloader:
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
dirname = _PostPathFormatter(post).format(self.dirname_pattern, target=target)
|
dirname = _PostPathFormatter(post, self.sanitize_paths).format(self.dirname_pattern, target=target)
|
||||||
filename_template = os.path.join(dirname, self.format_filename(post, target=target))
|
filename_template = os.path.join(dirname, self.format_filename(post, target=target))
|
||||||
filename = self.__prepare_filename(filename_template, lambda: post.url)
|
filename = self.__prepare_filename(filename_template, lambda: post.url)
|
||||||
|
|
||||||
@@ -846,17 +874,22 @@ class Instaloader:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
date_local = item.date_local
|
date_local = item.date_local
|
||||||
dirname = _PostPathFormatter(item).format(self.dirname_pattern, target=target)
|
dirname = _PostPathFormatter(item, self.sanitize_paths).format(self.dirname_pattern, target=target)
|
||||||
filename_template = os.path.join(dirname, self.format_filename(item, target=target))
|
filename_template = os.path.join(dirname, self.format_filename(item, target=target))
|
||||||
filename = self.__prepare_filename(filename_template, lambda: item.url)
|
filename = self.__prepare_filename(filename_template, lambda: item.url)
|
||||||
downloaded = False
|
downloaded = False
|
||||||
if not item.is_video or self.download_video_thumbnails is True:
|
video_url_fetch_failed = False
|
||||||
|
if item.is_video and self.download_videos is True:
|
||||||
|
video_url = item.video_url
|
||||||
|
if video_url:
|
||||||
|
filename = self.__prepare_filename(filename_template, lambda: str(video_url))
|
||||||
|
downloaded |= (not _already_downloaded(filename + ".mp4") and
|
||||||
|
self.download_pic(filename=filename, url=video_url, mtime=date_local))
|
||||||
|
else:
|
||||||
|
video_url_fetch_failed = True
|
||||||
|
if video_url_fetch_failed or not item.is_video or self.download_video_thumbnails is True:
|
||||||
downloaded = (not _already_downloaded(filename + ".jpg") and
|
downloaded = (not _already_downloaded(filename + ".jpg") and
|
||||||
self.download_pic(filename=filename, url=item.url, mtime=date_local))
|
self.download_pic(filename=filename, url=item.url, mtime=date_local))
|
||||||
if item.is_video and self.download_videos is True:
|
|
||||||
filename = self.__prepare_filename(filename_template, lambda: str(item.video_url))
|
|
||||||
downloaded |= (not _already_downloaded(filename + ".mp4") and
|
|
||||||
self.download_pic(filename=filename, url=item.video_url, mtime=date_local))
|
|
||||||
# Save caption if desired
|
# Save caption if desired
|
||||||
metadata_string = _ArbitraryItemFormatter(item).format(self.storyitem_metadata_txt_pattern).strip()
|
metadata_string = _ArbitraryItemFormatter(item).format(self.storyitem_metadata_txt_pattern).strip()
|
||||||
if metadata_string:
|
if metadata_string:
|
||||||
@@ -914,8 +947,9 @@ class Instaloader:
|
|||||||
name = user_highlight.owner_username
|
name = user_highlight.owner_username
|
||||||
highlight_target = (filename_target
|
highlight_target = (filename_target
|
||||||
if filename_target
|
if filename_target
|
||||||
else (Path(_PostPathFormatter.sanitize_path(name)) /
|
else (Path(_PostPathFormatter.sanitize_path(name, self.sanitize_paths)) /
|
||||||
_PostPathFormatter.sanitize_path(user_highlight.title))) # type: Union[str, Path]
|
_PostPathFormatter.sanitize_path(user_highlight.title,
|
||||||
|
self.sanitize_paths))) # type: Union[str, Path]
|
||||||
self.context.log("Retrieving highlights \"{}\" from profile {}".format(user_highlight.title, name))
|
self.context.log("Retrieving highlights \"{}\" from profile {}".format(user_highlight.title, name))
|
||||||
self.download_highlight_cover(user_highlight, highlight_target)
|
self.download_highlight_cover(user_highlight, highlight_target)
|
||||||
totalcount = user_highlight.itemcount
|
totalcount = user_highlight.itemcount
|
||||||
@@ -965,7 +999,7 @@ class Instaloader:
|
|||||||
else total_count)
|
else total_count)
|
||||||
sanitized_target = target
|
sanitized_target = target
|
||||||
if isinstance(target, str):
|
if isinstance(target, str):
|
||||||
sanitized_target = _PostPathFormatter.sanitize_path(target)
|
sanitized_target = _PostPathFormatter.sanitize_path(target, self.sanitize_paths)
|
||||||
if takewhile is None:
|
if takewhile is None:
|
||||||
takewhile = lambda _: True
|
takewhile = lambda _: True
|
||||||
with resumable_iteration(
|
with resumable_iteration(
|
||||||
@@ -980,7 +1014,10 @@ class Instaloader:
|
|||||||
enabled=self.resume_prefix is not None
|
enabled=self.resume_prefix is not None
|
||||||
) as (is_resuming, start_index):
|
) as (is_resuming, start_index):
|
||||||
for number, post in enumerate(posts, start=start_index + 1):
|
for number, post in enumerate(posts, start=start_index + 1):
|
||||||
if (max_count is not None and number > max_count) or not takewhile(post):
|
should_stop = not takewhile(post)
|
||||||
|
if should_stop and post.is_pinned:
|
||||||
|
continue
|
||||||
|
if (max_count is not None and number > max_count) or should_stop:
|
||||||
break
|
break
|
||||||
if displayed_count is not None:
|
if displayed_count is not None:
|
||||||
self.context.log("[{0:{w}d}/{1:{w}d}] ".format(number, displayed_count,
|
self.context.log("[{0:{w}d}/{1:{w}d}] ".format(number, displayed_count,
|
||||||
@@ -1012,7 +1049,7 @@ class Instaloader:
|
|||||||
except PostChangedException:
|
except PostChangedException:
|
||||||
post_changed = True
|
post_changed = True
|
||||||
continue
|
continue
|
||||||
if fast_update and not downloaded and not post_changed:
|
if fast_update and not downloaded and not post_changed and not post.is_pinned:
|
||||||
# disengage fast_update for first post when resuming
|
# disengage fast_update for first post when resuming
|
||||||
if not is_resuming or number > 0:
|
if not is_resuming or number > 0:
|
||||||
break
|
break
|
||||||
@@ -1043,7 +1080,7 @@ class Instaloader:
|
|||||||
'has_stories': False})["data"]
|
'has_stories': False})["data"]
|
||||||
|
|
||||||
@_requires_login
|
@_requires_login
|
||||||
def download_feed_posts(self, max_count: int = None, fast_update: bool = False,
|
def download_feed_posts(self, max_count: Optional[int] = None, fast_update: bool = False,
|
||||||
post_filter: Optional[Callable[[Post], bool]] = None) -> None:
|
post_filter: Optional[Callable[[Post], bool]] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Download pictures from the user's feed.
|
Download pictures from the user's feed.
|
||||||
@@ -1064,7 +1101,7 @@ class Instaloader:
|
|||||||
self.posts_download_loop(self.get_feed_posts(), ":feed", fast_update, post_filter, max_count=max_count)
|
self.posts_download_loop(self.get_feed_posts(), ":feed", fast_update, post_filter, max_count=max_count)
|
||||||
|
|
||||||
@_requires_login
|
@_requires_login
|
||||||
def download_saved_posts(self, max_count: int = None, fast_update: bool = False,
|
def download_saved_posts(self, max_count: Optional[int] = None, fast_update: bool = False,
|
||||||
post_filter: Optional[Callable[[Post], bool]] = None) -> None:
|
post_filter: Optional[Callable[[Post], bool]] = None) -> None:
|
||||||
"""Download user's saved pictures.
|
"""Download user's saved pictures.
|
||||||
|
|
||||||
@@ -1146,8 +1183,8 @@ class Instaloader:
|
|||||||
"""Get Posts associated with a #hashtag.
|
"""Get Posts associated with a #hashtag.
|
||||||
|
|
||||||
.. deprecated:: 4.4
|
.. deprecated:: 4.4
|
||||||
Use :meth:`Hashtag.get_posts`."""
|
Use :meth:`Hashtag.get_posts_resumable`."""
|
||||||
return Hashtag.from_name(self.context, hashtag).get_posts()
|
return Hashtag.from_name(self.context, hashtag).get_posts_resumable()
|
||||||
|
|
||||||
def download_hashtag(self, hashtag: Union[Hashtag, str],
|
def download_hashtag(self, hashtag: Union[Hashtag, str],
|
||||||
max_count: Optional[int] = None,
|
max_count: Optional[int] = None,
|
||||||
@@ -1183,7 +1220,7 @@ class Instaloader:
|
|||||||
self.download_hashtag_profilepic(hashtag)
|
self.download_hashtag_profilepic(hashtag)
|
||||||
if posts:
|
if posts:
|
||||||
self.context.log("Retrieving pictures with hashtag #{}...".format(hashtag.name))
|
self.context.log("Retrieving pictures with hashtag #{}...".format(hashtag.name))
|
||||||
self.posts_download_loop(hashtag.get_all_posts(), target, fast_update, post_filter,
|
self.posts_download_loop(hashtag.get_posts_resumable(), target, fast_update, post_filter,
|
||||||
max_count=max_count)
|
max_count=max_count)
|
||||||
if self.save_metadata:
|
if self.save_metadata:
|
||||||
json_filename = '{0}/{1}'.format(self.dirname_pattern.format(profile=target,
|
json_filename = '{0}/{1}'.format(self.dirname_pattern.format(profile=target,
|
||||||
@@ -1209,8 +1246,8 @@ class Instaloader:
|
|||||||
tagged_posts = profile.get_tagged_posts()
|
tagged_posts = profile.get_tagged_posts()
|
||||||
self.posts_download_loop(tagged_posts,
|
self.posts_download_loop(tagged_posts,
|
||||||
target if target
|
target if target
|
||||||
else (Path(_PostPathFormatter.sanitize_path(profile.username)) /
|
else (Path(_PostPathFormatter.sanitize_path(profile.username, self.sanitize_paths)) /
|
||||||
_PostPathFormatter.sanitize_path(':tagged')),
|
_PostPathFormatter.sanitize_path(':tagged', self.sanitize_paths)),
|
||||||
fast_update, post_filter, takewhile=posts_takewhile)
|
fast_update, post_filter, takewhile=posts_takewhile)
|
||||||
if latest_stamps is not None and tagged_posts.first_item is not None:
|
if latest_stamps is not None and tagged_posts.first_item is not None:
|
||||||
latest_stamps.set_last_tagged_timestamp(profile.username, tagged_posts.first_item.date_local)
|
latest_stamps.set_last_tagged_timestamp(profile.username, tagged_posts.first_item.date_local)
|
||||||
@@ -1304,6 +1341,10 @@ class Instaloader:
|
|||||||
profile_id))
|
profile_id))
|
||||||
profile_from_id = Profile.from_id(self.context, profile_id)
|
profile_from_id = Profile.from_id(self.context, profile_id)
|
||||||
newname = profile_from_id.username
|
newname = profile_from_id.username
|
||||||
|
if profile_name == newname:
|
||||||
|
self.context.error(
|
||||||
|
f"Warning: Profile {profile_name} could not be retrieved by its name, but by its ID.")
|
||||||
|
return profile_from_id
|
||||||
self.context.error("Profile {0} has changed its name to {1}.".format(profile_name, newname))
|
self.context.error("Profile {0} has changed its name to {1}.".format(profile_name, newname))
|
||||||
if latest_stamps is None:
|
if latest_stamps is None:
|
||||||
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
if ((format_string_contains_key(self.dirname_pattern, 'profile') or
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ def copy_session(session: requests.Session, request_timeout: Optional[float] = N
|
|||||||
"""Duplicates a requests.Session."""
|
"""Duplicates a requests.Session."""
|
||||||
new = requests.Session()
|
new = requests.Session()
|
||||||
new.cookies = requests.utils.cookiejar_from_dict(requests.utils.dict_from_cookiejar(session.cookies))
|
new.cookies = requests.utils.cookiejar_from_dict(requests.utils.dict_from_cookiejar(session.cookies))
|
||||||
new.headers = session.headers.copy()
|
new.headers = session.headers.copy() # type: ignore
|
||||||
# Override default timeout behavior.
|
# Override default timeout behavior.
|
||||||
# Need to silence mypy bug for this. See: https://github.com/python/mypy/issues/2427
|
# Need to silence mypy bug for this. See: https://github.com/python/mypy/issues/2427
|
||||||
new.request = partial(new.request, timeout=request_timeout) # type: ignore
|
new.request = partial(new.request, timeout=request_timeout) # type: ignore
|
||||||
return new
|
return new
|
||||||
|
|
||||||
|
|
||||||
@@ -331,7 +331,8 @@ class InstaloaderContext:
|
|||||||
while resp.is_redirect:
|
while resp.is_redirect:
|
||||||
redirect_url = resp.headers['location']
|
redirect_url = resp.headers['location']
|
||||||
self.log('\nHTTP redirect from https://{0}/{1} to {2}'.format(host, path, redirect_url))
|
self.log('\nHTTP redirect from https://{0}/{1} to {2}'.format(host, path, redirect_url))
|
||||||
if redirect_url.startswith('https://www.instagram.com/accounts/login'):
|
if (redirect_url.startswith('https://www.instagram.com/accounts/login') or
|
||||||
|
redirect_url.startswith('https://i.instagram.com/accounts/login')):
|
||||||
if not self.is_logged_in:
|
if not self.is_logged_in:
|
||||||
raise LoginRequiredException("Redirected to login page. Use --login.")
|
raise LoginRequiredException("Redirected to login page. Use --login.")
|
||||||
# alternate rate limit exceeded behavior
|
# alternate rate limit exceeded behavior
|
||||||
@@ -589,7 +590,6 @@ class RateController:
|
|||||||
"""Wait given number of seconds."""
|
"""Wait given number of seconds."""
|
||||||
# Not static, to allow for the behavior of this method to depend on context-inherent properties, such as
|
# Not static, to allow for the behavior of this method to depend on context-inherent properties, such as
|
||||||
# whether we are logged in.
|
# whether we are logged in.
|
||||||
# pylint:disable=no-self-use
|
|
||||||
time.sleep(secs)
|
time.sleep(secs)
|
||||||
|
|
||||||
def _dump_query_timestamps(self, current_time: float, failed_query_type: str):
|
def _dump_query_timestamps(self, current_time: float, failed_query_type: str):
|
||||||
@@ -613,7 +613,6 @@ class RateController:
|
|||||||
control."""
|
control."""
|
||||||
# Not static, to allow for the count_per_sliding_window to depend on context-inherent properties, such as
|
# Not static, to allow for the count_per_sliding_window to depend on context-inherent properties, such as
|
||||||
# whether we are logged in.
|
# whether we are logged in.
|
||||||
# pylint:disable=no-self-use
|
|
||||||
return 75 if query_type == 'other' else 200
|
return 75 if query_type == 'other' else 200
|
||||||
|
|
||||||
def _reqs_in_sliding_window(self, query_type: Optional[str], current_time: float, window: float) -> List[float]:
|
def _reqs_in_sliding_window(self, query_type: Optional[str], current_time: float, window: float) -> List[float]:
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ class NodeIterator(Iterator[T]):
|
|||||||
node_wrapper: Callable[[Dict], T],
|
node_wrapper: Callable[[Dict], T],
|
||||||
query_variables: Optional[Dict[str, Any]] = None,
|
query_variables: Optional[Dict[str, Any]] = None,
|
||||||
query_referer: Optional[str] = None,
|
query_referer: Optional[str] = None,
|
||||||
first_data: Optional[Dict[str, Any]] = None):
|
first_data: Optional[Dict[str, Any]] = None,
|
||||||
|
is_first: Optional[Callable[[T, Optional[T]], bool]] = None):
|
||||||
self._context = context
|
self._context = context
|
||||||
self._query_hash = query_hash
|
self._query_hash = query_hash
|
||||||
self._edge_extractor = edge_extractor
|
self._edge_extractor = edge_extractor
|
||||||
@@ -91,6 +92,7 @@ class NodeIterator(Iterator[T]):
|
|||||||
else:
|
else:
|
||||||
self._data = self._query()
|
self._data = self._query()
|
||||||
self._first_node: Optional[Dict] = None
|
self._first_node: Optional[Dict] = None
|
||||||
|
self._is_first = is_first
|
||||||
|
|
||||||
def _query(self, after: Optional[str] = None) -> Dict:
|
def _query(self, after: Optional[str] = None) -> Dict:
|
||||||
pagination_variables = {'first': NodeIterator._graphql_page_length} # type: Dict[str, Any]
|
pagination_variables = {'first': NodeIterator._graphql_page_length} # type: Dict[str, Any]
|
||||||
@@ -128,12 +130,16 @@ class NodeIterator(Iterator[T]):
|
|||||||
self._page_index, self._total_index = page_index, total_index
|
self._page_index, self._total_index = page_index, total_index
|
||||||
raise
|
raise
|
||||||
item = self._node_wrapper(node)
|
item = self._node_wrapper(node)
|
||||||
if self._first_node is None:
|
if self._is_first is not None:
|
||||||
self._first_node = node
|
if self._is_first(item, self.first_item):
|
||||||
|
self._first_node = node
|
||||||
|
else:
|
||||||
|
if self._first_node is None:
|
||||||
|
self._first_node = node
|
||||||
return item
|
return item
|
||||||
if self._data['page_info']['has_next_page']:
|
if self._data['page_info']['has_next_page']:
|
||||||
query_response = self._query(self._data['page_info']['end_cursor'])
|
query_response = self._query(self._data['page_info']['end_cursor'])
|
||||||
if self._data['edges'] != query_response['edges']:
|
if self._data['edges'] != query_response['edges'] and len(query_response['edges']) > 0:
|
||||||
page_index, data = self._page_index, self._data
|
page_index, data = self._page_index, self._data
|
||||||
try:
|
try:
|
||||||
self._page_index = 0
|
self._page_index = 0
|
||||||
@@ -168,7 +174,13 @@ class NodeIterator(Iterator[T]):
|
|||||||
"""
|
"""
|
||||||
If this iterator has produced any items, returns the first item produced.
|
If this iterator has produced any items, returns the first item produced.
|
||||||
|
|
||||||
|
It is possible to override what is considered the first item (for example, to consider the
|
||||||
|
newest item in case items are not in strict chronological order) by passing a callback
|
||||||
|
function as the `is_first` parameter when creating the class.
|
||||||
|
|
||||||
.. versionadded:: 4.8
|
.. versionadded:: 4.8
|
||||||
|
.. versionchanged:: 4.9.2
|
||||||
|
What is considered the first item can be overridden.
|
||||||
"""
|
"""
|
||||||
return self._node_wrapper(self._first_node) if self._first_node is not None else None
|
return self._node_wrapper(self._first_node) if self._first_node is not None else None
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class SectionIterator(Iterator[T]):
|
|||||||
def _query(self, max_id: Optional[str] = None) -> Dict[str, Any]:
|
def _query(self, max_id: Optional[str] = None) -> Dict[str, Any]:
|
||||||
pagination_variables = {"max_id": max_id} if max_id is not None else {}
|
pagination_variables = {"max_id": max_id} if max_id is not None else {}
|
||||||
return self._sections_extractor(
|
return self._sections_extractor(
|
||||||
self._context.get_json(self._query_path, params={"__a": 1, **pagination_variables})
|
self._context.get_json(self._query_path, params={"__a": 1, "__d": "dis", **pagination_variables})
|
||||||
)
|
)
|
||||||
|
|
||||||
def __next__(self) -> T:
|
def __next__(self) -> T:
|
||||||
|
|||||||
+110
-22
@@ -7,7 +7,8 @@ from contextlib import suppress
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from itertools import islice
|
from itertools import islice
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union
|
from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple, Union
|
||||||
|
from unicodedata import normalize
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .exceptions import *
|
from .exceptions import *
|
||||||
@@ -292,7 +293,7 @@ class Post:
|
|||||||
url = re.sub(r'([?&])se=\d+&?', r'\1', orig_url).rstrip('&')
|
url = re.sub(r'([?&])se=\d+&?', r'\1', orig_url).rstrip('&')
|
||||||
return url
|
return url
|
||||||
except (InstaloaderException, KeyError, IndexError) as err:
|
except (InstaloaderException, KeyError, IndexError) as err:
|
||||||
self._context.error('{} Unable to fetch high quality image version of {}.'.format(err, self))
|
self._context.error(f"Unable to fetch high quality image version of {self}: {err}")
|
||||||
return self._node["display_url"] if "display_url" in self._node else self._node["display_src"]
|
return self._node["display_url"] if "display_url" in self._node else self._node["display_src"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -356,18 +357,23 @@ class Post:
|
|||||||
orig_url = carousel_media[idx]['image_versions2']['candidates'][0]['url']
|
orig_url = carousel_media[idx]['image_versions2']['candidates'][0]['url']
|
||||||
display_url = re.sub(r'([?&])se=\d+&?', r'\1', orig_url).rstrip('&')
|
display_url = re.sub(r'([?&])se=\d+&?', r'\1', orig_url).rstrip('&')
|
||||||
except (InstaloaderException, KeyError, IndexError) as err:
|
except (InstaloaderException, KeyError, IndexError) as err:
|
||||||
self._context.error('{} Unable to fetch high quality image version of {}.'.format(
|
self._context.error(f"Unable to fetch high quality image version of {self}: {err}")
|
||||||
err, self))
|
|
||||||
yield PostSidecarNode(is_video=is_video, display_url=display_url,
|
yield PostSidecarNode(is_video=is_video, display_url=display_url,
|
||||||
video_url=node['video_url'] if is_video else None)
|
video_url=node['video_url'] if is_video else None)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def caption(self) -> Optional[str]:
|
def caption(self) -> Optional[str]:
|
||||||
"""Caption."""
|
"""Caption."""
|
||||||
|
def _normalize(string: Optional[str]) -> Optional[str]:
|
||||||
|
if string is not None:
|
||||||
|
return normalize("NFC", string)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
if "edge_media_to_caption" in self._node and self._node["edge_media_to_caption"]["edges"]:
|
if "edge_media_to_caption" in self._node and self._node["edge_media_to_caption"]["edges"]:
|
||||||
return self._node["edge_media_to_caption"]["edges"][0]["node"]["text"]
|
return _normalize(self._node["edge_media_to_caption"]["edges"][0]["node"]["text"])
|
||||||
elif "caption" in self._node:
|
elif "caption" in self._node:
|
||||||
return self._node["caption"]
|
return _normalize(self._node["caption"])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -389,7 +395,7 @@ class Post:
|
|||||||
# support Unicode and a word/beginning of string delimiter at the beginning to ensure
|
# support Unicode and a word/beginning of string delimiter at the beginning to ensure
|
||||||
# that no email addresses join the list of mentions.
|
# that no email addresses join the list of mentions.
|
||||||
# http://blog.jstassen.com/2016/03/code-regex-for-instagram-username-and-hashtags/
|
# http://blog.jstassen.com/2016/03/code-regex-for-instagram-username-and-hashtags/
|
||||||
mention_regex = re.compile(r"(?:^|\W|_)(?:@)(\w(?:(?:\w|(?:\.(?!\.))){0,28}(?:\w))?)")
|
mention_regex = re.compile(r"(?:^|\W|_)(?:@)(\w(?:(?:\w|(?:\.(?!\.))){0,28}(?:\w))?)", re.ASCII)
|
||||||
return re.findall(mention_regex, self.caption.lower())
|
return re.findall(mention_regex, self.caption.lower())
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -399,7 +405,7 @@ class Post:
|
|||||||
.. versionadded:: 4.2.6"""
|
.. versionadded:: 4.2.6"""
|
||||||
def _elliptify(caption):
|
def _elliptify(caption):
|
||||||
pcaption = ' '.join([s.replace('/', '\u2215') for s in caption.splitlines() if s]).strip()
|
pcaption = ' '.join([s.replace('/', '\u2215') for s in caption.splitlines() if s]).strip()
|
||||||
return (pcaption[:30] + u"\u2026") if len(pcaption) > 31 else pcaption
|
return (pcaption[:30] + "\u2026") if len(pcaption) > 31 else pcaption
|
||||||
return _elliptify(self.caption) if self.caption else ''
|
return _elliptify(self.caption) if self.caption else ''
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -552,7 +558,7 @@ class Post:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
comment_edges = self._field('edge_media_to_comment', 'edges')
|
comment_edges = self._field('edge_media_to_comment', 'edges')
|
||||||
answers_count = sum([edge['node'].get('edge_threaded_comments', {}).get('count', 0) for edge in comment_edges])
|
answers_count = sum(edge['node'].get('edge_threaded_comments', {}).get('count', 0) for edge in comment_edges)
|
||||||
|
|
||||||
if self.comments == len(comment_edges) + answers_count:
|
if self.comments == len(comment_edges) + answers_count:
|
||||||
# If the Post's metadata already contains all parent comments, don't do GraphQL requests to obtain them
|
# If the Post's metadata already contains all parent comments, don't do GraphQL requests to obtain them
|
||||||
@@ -632,11 +638,18 @@ class Post:
|
|||||||
location_id = int(loc['id'])
|
location_id = int(loc['id'])
|
||||||
if any(k not in loc for k in ('name', 'slug', 'has_public_page', 'lat', 'lng')):
|
if any(k not in loc for k in ('name', 'slug', 'has_public_page', 'lat', 'lng')):
|
||||||
loc.update(self._context.get_json("explore/locations/{0}/".format(location_id),
|
loc.update(self._context.get_json("explore/locations/{0}/".format(location_id),
|
||||||
params={'__a': 1})['native_location_data']['location_info'])
|
params={'__a': 1, '__d': 'dis'})['native_location_data']['location_info'])
|
||||||
self._location = PostLocation(location_id, loc['name'], loc['slug'], loc['has_public_page'],
|
self._location = PostLocation(location_id, loc['name'], loc['slug'], loc['has_public_page'],
|
||||||
loc.get('lat'), loc.get('lng'))
|
loc.get('lat'), loc.get('lng'))
|
||||||
return self._location
|
return self._location
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_pinned(self) -> bool:
|
||||||
|
"""True if this Post has been pinned by at least one user.
|
||||||
|
|
||||||
|
.. versionadded: 4.9.2"""
|
||||||
|
return 'pinned_for_users' in self._node and bool(self._node['pinned_for_users'])
|
||||||
|
|
||||||
|
|
||||||
class Profile:
|
class Profile:
|
||||||
"""
|
"""
|
||||||
@@ -757,13 +770,19 @@ class Profile:
|
|||||||
def _obtain_metadata(self):
|
def _obtain_metadata(self):
|
||||||
try:
|
try:
|
||||||
if not self._has_full_metadata:
|
if not self._has_full_metadata:
|
||||||
metadata = self._context.get_json('{}/feed/'.format(self.username), params={})
|
metadata = self._context.get_iphone_json(f'api/v1/users/web_profile_info/?username={self.username}',
|
||||||
self._node = metadata['entry_data']['ProfilePage'][0]['graphql']['user']
|
params={})
|
||||||
|
if metadata['data']['user'] is None:
|
||||||
|
raise ProfileNotExistsException('Profile {} does not exist.'.format(self.username))
|
||||||
|
self._node = metadata['data']['user']
|
||||||
self._has_full_metadata = True
|
self._has_full_metadata = True
|
||||||
except (QueryReturnedNotFoundException, KeyError) as err:
|
except (QueryReturnedNotFoundException, KeyError) as err:
|
||||||
top_search_results = TopSearchResults(self._context, self.username)
|
top_search_results = TopSearchResults(self._context, self.username)
|
||||||
similar_profiles = [profile.username for profile in top_search_results.get_profiles()]
|
similar_profiles = [profile.username for profile in top_search_results.get_profiles()]
|
||||||
if similar_profiles:
|
if similar_profiles:
|
||||||
|
if self.username in similar_profiles:
|
||||||
|
raise ProfileNotExistsException(
|
||||||
|
f"Profile {self.username} seems to exist, but could not be loaded.") from err
|
||||||
raise ProfileNotExistsException('Profile {} does not exist.\nThe most similar profile{}: {}.'
|
raise ProfileNotExistsException('Profile {} does not exist.\nThe most similar profile{}: {}.'
|
||||||
.format(self.username,
|
.format(self.username,
|
||||||
's are' if len(similar_profiles) > 1 else ' is',
|
's are' if len(similar_profiles) > 1 else ' is',
|
||||||
@@ -933,11 +952,18 @@ class Profile:
|
|||||||
try:
|
try:
|
||||||
return self._iphone_struct['hd_profile_pic_url_info']['url']
|
return self._iphone_struct['hd_profile_pic_url_info']['url']
|
||||||
except (InstaloaderException, KeyError) as err:
|
except (InstaloaderException, KeyError) as err:
|
||||||
self._context.error('{} Unable to fetch high quality profile pic.'.format(err))
|
self._context.error(f"Unable to fetch high quality profile pic: {err}")
|
||||||
return self._metadata("profile_pic_url_hd")
|
return self._metadata("profile_pic_url_hd")
|
||||||
else:
|
else:
|
||||||
return self._metadata("profile_pic_url_hd")
|
return self._metadata("profile_pic_url_hd")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def profile_pic_url_no_iphone(self) -> str:
|
||||||
|
"""Return URL of lower-quality profile picture.
|
||||||
|
|
||||||
|
.. versionadded:: 4.9.3"""
|
||||||
|
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
|
||||||
|
|
||||||
@@ -957,6 +983,7 @@ class Profile:
|
|||||||
{'id': self.userid},
|
{'id': self.userid},
|
||||||
'https://www.instagram.com/{0}/'.format(self.username),
|
'https://www.instagram.com/{0}/'.format(self.username),
|
||||||
self._metadata('edge_owner_to_timeline_media'),
|
self._metadata('edge_owner_to_timeline_media'),
|
||||||
|
Profile._make_is_newest_checker()
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_saved_posts(self) -> NodeIterator[Post]:
|
def get_saved_posts(self) -> NodeIterator[Post]:
|
||||||
@@ -990,6 +1017,7 @@ class Profile:
|
|||||||
lambda n: Post(self._context, n, self if int(n['owner']['id']) == self.userid else None),
|
lambda n: Post(self._context, n, self if int(n['owner']['id']) == self.userid else None),
|
||||||
{'id': self.userid},
|
{'id': self.userid},
|
||||||
'https://www.instagram.com/{0}/'.format(self.username),
|
'https://www.instagram.com/{0}/'.format(self.username),
|
||||||
|
is_first=Profile._make_is_newest_checker()
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_igtv_posts(self) -> NodeIterator[Post]:
|
def get_igtv_posts(self) -> NodeIterator[Post]:
|
||||||
@@ -1007,8 +1035,13 @@ class Profile:
|
|||||||
{'id': self.userid},
|
{'id': self.userid},
|
||||||
'https://www.instagram.com/{0}/channel/'.format(self.username),
|
'https://www.instagram.com/{0}/channel/'.format(self.username),
|
||||||
self._metadata('edge_felix_video_timeline'),
|
self._metadata('edge_felix_video_timeline'),
|
||||||
|
Profile._make_is_newest_checker()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_is_newest_checker() -> Callable[[Post, Optional[Post]], bool]:
|
||||||
|
return lambda post, first: first is None or post.date_local > first.date_local
|
||||||
|
|
||||||
def get_followers(self) -> NodeIterator['Profile']:
|
def get_followers(self) -> NodeIterator['Profile']:
|
||||||
"""
|
"""
|
||||||
Retrieve list of followers of given profile.
|
Retrieve list of followers of given profile.
|
||||||
@@ -1114,6 +1147,21 @@ class StoryItem:
|
|||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
return hash(self.mediaid)
|
return hash(self.mediaid)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mediaid(cls, context: InstaloaderContext, mediaid: int):
|
||||||
|
"""Create a StoryItem object from a given mediaid.
|
||||||
|
|
||||||
|
.. versionadded:: 4.9
|
||||||
|
"""
|
||||||
|
pic_json = context.graphql_query(
|
||||||
|
'2b0673e0dc4580674a88d426fe00ea90',
|
||||||
|
{'shortcode': Post.mediaid_to_shortcode(mediaid)}
|
||||||
|
)
|
||||||
|
shortcode_media = pic_json['data']['shortcode_media']
|
||||||
|
if shortcode_media is None:
|
||||||
|
raise BadResponseException("Fetching StoryItem metadata failed.")
|
||||||
|
return cls(context, shortcode_media)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _iphone_struct(self) -> Dict[str, Any]:
|
def _iphone_struct(self) -> Dict[str, Any]:
|
||||||
if not self._context.iphone_support:
|
if not self._context.iphone_support:
|
||||||
@@ -1121,8 +1169,14 @@ class StoryItem:
|
|||||||
if not self._context.is_logged_in:
|
if not self._context.is_logged_in:
|
||||||
raise LoginRequiredException("--login required to access iPhone media info endpoint.")
|
raise LoginRequiredException("--login required to access iPhone media info endpoint.")
|
||||||
if not self._iphone_struct_:
|
if not self._iphone_struct_:
|
||||||
data = self._context.get_iphone_json(path='api/v1/media/{}/info/'.format(self.mediaid), params={})
|
data = self._context.get_iphone_json(
|
||||||
self._iphone_struct_ = data['items'][0]
|
path='api/v1/feed/reels_media/?reel_ids={}'.format(self.owner_id), params={}
|
||||||
|
)
|
||||||
|
self._iphone_struct_ = {}
|
||||||
|
for item in data['reels'][str(self.owner_id)]['items']:
|
||||||
|
if item['pk'] == self.mediaid:
|
||||||
|
self._iphone_struct_ = item
|
||||||
|
break
|
||||||
return self._iphone_struct_
|
return self._iphone_struct_
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -1179,13 +1233,14 @@ class StoryItem:
|
|||||||
@property
|
@property
|
||||||
def url(self) -> str:
|
def url(self) -> str:
|
||||||
"""URL of the picture / video thumbnail of the StoryItem"""
|
"""URL of the picture / video thumbnail of the StoryItem"""
|
||||||
if self.typename == "GraphStoryImage" and self._context.iphone_support and self._context.is_logged_in:
|
if self.typename in ["GraphStoryImage", "StoryImage"] and \
|
||||||
|
self._context.iphone_support and self._context.is_logged_in:
|
||||||
try:
|
try:
|
||||||
orig_url = self._iphone_struct['image_versions2']['candidates'][0]['url']
|
orig_url = self._iphone_struct['image_versions2']['candidates'][0]['url']
|
||||||
url = re.sub(r'([?&])se=\d+&?', r'\1', orig_url).rstrip('&')
|
url = re.sub(r'([?&])se=\d+&?', r'\1', orig_url).rstrip('&')
|
||||||
return url
|
return url
|
||||||
except (InstaloaderException, KeyError, IndexError) as err:
|
except (InstaloaderException, KeyError, IndexError) as err:
|
||||||
self._context.error('{} Unable to fetch high quality image version of {}.'.format(err, self))
|
self._context.error(f"Unable to fetch high quality image version of {self}: {err}")
|
||||||
return self._node['display_resources'][-1]['src']
|
return self._node['display_resources'][-1]['src']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -1260,6 +1315,7 @@ class Story:
|
|||||||
self._node = node
|
self._node = node
|
||||||
self._unique_id = None # type: Optional[str]
|
self._unique_id = None # type: Optional[str]
|
||||||
self._owner_profile = None # type: Optional[Profile]
|
self._owner_profile = None # type: Optional[Profile]
|
||||||
|
self._iphone_struct_ = None # type: Optional[Dict[str, Any]]
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<Story by {} changed {:%Y-%m-%d_%H-%M-%S_UTC}>'.format(self.owner_username, self.latest_media_utc)
|
return '<Story by {} changed {:%Y-%m-%d_%H-%M-%S_UTC}>'.format(self.owner_username, self.latest_media_utc)
|
||||||
@@ -1330,9 +1386,23 @@ class Story:
|
|||||||
"""The story owner's ID."""
|
"""The story owner's ID."""
|
||||||
return self.owner_profile.userid
|
return self.owner_profile.userid
|
||||||
|
|
||||||
|
def _fetch_iphone_struct(self) -> None:
|
||||||
|
if self._context.iphone_support and self._context.is_logged_in and not self._iphone_struct_:
|
||||||
|
data = self._context.get_iphone_json(
|
||||||
|
path='api/v1/feed/reels_media/?reel_ids={}'.format(self.owner_id), params={}
|
||||||
|
)
|
||||||
|
self._iphone_struct_ = data['reels'][str(self.owner_id)]
|
||||||
|
|
||||||
def get_items(self) -> Iterator[StoryItem]:
|
def get_items(self) -> Iterator[StoryItem]:
|
||||||
"""Retrieve all items from a story."""
|
"""Retrieve all items from a story."""
|
||||||
yield from (StoryItem(self._context, item, self.owner_profile) for item in reversed(self._node['items']))
|
self._fetch_iphone_struct()
|
||||||
|
for item in reversed(self._node['items']):
|
||||||
|
if self._iphone_struct_ is not None:
|
||||||
|
for iphone_struct_item in self._iphone_struct_['items']:
|
||||||
|
if iphone_struct_item['pk'] == int(item['id']):
|
||||||
|
item['iphone_struct'] = iphone_struct_item
|
||||||
|
break
|
||||||
|
yield StoryItem(self._context, item, self.owner_profile)
|
||||||
|
|
||||||
|
|
||||||
class Highlight(Story):
|
class Highlight(Story):
|
||||||
@@ -1362,6 +1432,7 @@ class Highlight(Story):
|
|||||||
super().__init__(context, node)
|
super().__init__(context, node)
|
||||||
self._owner_profile = owner
|
self._owner_profile = owner
|
||||||
self._items = None # type: Optional[List[Dict[str, Any]]]
|
self._items = None # type: Optional[List[Dict[str, Any]]]
|
||||||
|
self._iphone_struct_ = None # type: Optional[Dict[str, Any]]
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<Highlight by {}: {}>'.format(self.owner_username, self.title)
|
return '<Highlight by {}: {}>'.format(self.owner_username, self.title)
|
||||||
@@ -1400,6 +1471,13 @@ class Highlight(Story):
|
|||||||
"highlight_reel_ids": [str(self.unique_id)],
|
"highlight_reel_ids": [str(self.unique_id)],
|
||||||
"precomposed_overlay": False})['data']['reels_media'][0]['items']
|
"precomposed_overlay": False})['data']['reels_media'][0]['items']
|
||||||
|
|
||||||
|
def _fetch_iphone_struct(self) -> None:
|
||||||
|
if self._context.iphone_support and self._context.is_logged_in and not self._iphone_struct_:
|
||||||
|
data = self._context.get_iphone_json(
|
||||||
|
path='api/v1/feed/reels_media/?reel_ids=highlight:{}'.format(self.unique_id), params={}
|
||||||
|
)
|
||||||
|
self._iphone_struct_ = data['reels']['highlight:{}'.format(self.unique_id)]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def itemcount(self) -> int:
|
def itemcount(self) -> int:
|
||||||
"""Count of items associated with the :class:`Highlight` instance."""
|
"""Count of items associated with the :class:`Highlight` instance."""
|
||||||
@@ -1410,8 +1488,15 @@ class Highlight(Story):
|
|||||||
def get_items(self) -> Iterator[StoryItem]:
|
def get_items(self) -> Iterator[StoryItem]:
|
||||||
"""Retrieve all associated highlight items."""
|
"""Retrieve all associated highlight items."""
|
||||||
self._fetch_items()
|
self._fetch_items()
|
||||||
|
self._fetch_iphone_struct()
|
||||||
assert self._items is not None
|
assert self._items is not None
|
||||||
yield from (StoryItem(self._context, item, self.owner_profile) for item in self._items)
|
for item in self._items:
|
||||||
|
if self._iphone_struct_ is not None:
|
||||||
|
for iphone_struct_item in self._iphone_struct_['items']:
|
||||||
|
if iphone_struct_item['pk'] == int(item['id']):
|
||||||
|
item['iphone_struct'] = iphone_struct_item
|
||||||
|
break
|
||||||
|
yield StoryItem(self._context, item, self.owner_profile)
|
||||||
|
|
||||||
|
|
||||||
class Hashtag:
|
class Hashtag:
|
||||||
@@ -1465,7 +1550,7 @@ class Hashtag:
|
|||||||
|
|
||||||
def _obtain_metadata(self):
|
def _obtain_metadata(self):
|
||||||
if not self._has_full_metadata:
|
if not self._has_full_metadata:
|
||||||
self._node = self._query({"__a": 1})
|
self._node = self._query({"__a": 1, "__d": "dis"})
|
||||||
self._has_full_metadata = True
|
self._has_full_metadata = True
|
||||||
|
|
||||||
def _asdict(self):
|
def _asdict(self):
|
||||||
@@ -1552,7 +1637,10 @@ class Hashtag:
|
|||||||
return self._metadata("media_count")
|
return self._metadata("media_count")
|
||||||
|
|
||||||
def get_posts(self) -> Iterator[Post]:
|
def get_posts(self) -> Iterator[Post]:
|
||||||
"""Yields the recent posts associated with this hashtag."""
|
"""Yields the recent posts associated with this hashtag.
|
||||||
|
|
||||||
|
.. deprecated:: 4.9
|
||||||
|
Use :meth:`Hashtag.get_posts_resumable` as this method may return incorrect results (:issue:`1457`)"""
|
||||||
try:
|
try:
|
||||||
self._metadata("edge_hashtag_to_media", "edges")
|
self._metadata("edge_hashtag_to_media", "edges")
|
||||||
self._metadata("edge_hashtag_to_media", "page_info")
|
self._metadata("edge_hashtag_to_media", "page_info")
|
||||||
@@ -1574,7 +1662,7 @@ class Hashtag:
|
|||||||
def get_all_posts(self) -> Iterator[Post]:
|
def get_all_posts(self) -> Iterator[Post]:
|
||||||
"""Yields all posts, i.e. all most recent posts and the top posts, in almost-chronological order."""
|
"""Yields all posts, i.e. all most recent posts and the top posts, in almost-chronological order."""
|
||||||
sorted_top_posts = iter(sorted(islice(self.get_top_posts(), 9), key=lambda p: p.date_utc, reverse=True))
|
sorted_top_posts = iter(sorted(islice(self.get_top_posts(), 9), key=lambda p: p.date_utc, reverse=True))
|
||||||
other_posts = self.get_posts()
|
other_posts = self.get_posts_resumable()
|
||||||
next_top = next(sorted_top_posts, None)
|
next_top = next(sorted_top_posts, None)
|
||||||
next_other = next(other_posts, None)
|
next_other = next(other_posts, None)
|
||||||
while next_top is not None or next_other is not None:
|
while next_top is not None or next_other is not None:
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ def get_version():
|
|||||||
raise SystemExit("Could not find version string.")
|
raise SystemExit("Could not find version string.")
|
||||||
|
|
||||||
|
|
||||||
if sys.version_info < (3, 6):
|
if sys.version_info < (3, 8):
|
||||||
sys.exit('Instaloader requires Python >= 3.6.')
|
sys.exit('Instaloader requires Python >= 3.8.')
|
||||||
|
|
||||||
requirements = ['requests>=2.4']
|
requirements = ['requests>=2.4']
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ setup(
|
|||||||
'from Instagram.',
|
'from Instagram.',
|
||||||
long_description=open(os.path.join(SRC, 'README.rst')).read(),
|
long_description=open(os.path.join(SRC, 'README.rst')).read(),
|
||||||
install_requires=requirements,
|
install_requires=requirements,
|
||||||
python_requires='>=3.6',
|
python_requires='>=3.8',
|
||||||
entry_points={'console_scripts': ['instaloader=instaloader.__main__:main']},
|
entry_points={'console_scripts': ['instaloader=instaloader.__main__:main']},
|
||||||
zip_safe=False,
|
zip_safe=False,
|
||||||
keywords=keywords,
|
keywords=keywords,
|
||||||
@@ -54,10 +54,10 @@ setup(
|
|||||||
'Intended Audience :: Developers',
|
'Intended Audience :: Developers',
|
||||||
'License :: OSI Approved :: MIT License',
|
'License :: OSI Approved :: MIT License',
|
||||||
'Operating System :: OS Independent',
|
'Operating System :: OS Independent',
|
||||||
'Programming Language :: Python :: 3.6',
|
|
||||||
'Programming Language :: Python :: 3.7',
|
|
||||||
'Programming Language :: Python :: 3.8',
|
'Programming Language :: Python :: 3.8',
|
||||||
'Programming Language :: Python :: 3.9',
|
'Programming Language :: Python :: 3.9',
|
||||||
|
'Programming Language :: Python :: 3.10',
|
||||||
|
'Programming Language :: Python :: 3.11',
|
||||||
'Programming Language :: Python :: 3 :: Only',
|
'Programming Language :: Python :: 3 :: Only',
|
||||||
'Topic :: Internet',
|
'Topic :: Internet',
|
||||||
'Topic :: Multimedia :: Graphics'
|
'Topic :: Multimedia :: Graphics'
|
||||||
|
|||||||
Reference in New Issue
Block a user