mirror of
https://github.com/instaloader/instaloader.git
synced 2026-10-23 08:59:38 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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==2022.1.8
|
||||||
|
pipenv sync --dev
|
||||||
|
- name: "Build Documentation"
|
||||||
|
run: pipenv 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 }}
|
||||||
@@ -8,24 +8,19 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.8", "3.9", "3.10"]
|
python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"]
|
||||||
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==2022.1.8
|
||||||
pipenv sync --dev
|
pipenv sync --dev
|
||||||
- name: PyLint
|
- name: PyLint
|
||||||
run: pipenv run pylint instaloader
|
run: pipenv run pylint instaloader
|
||||||
- name: MyPy
|
- name: MyPy
|
||||||
run: pipenv run mypy -m instaloader
|
run: pipenv 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 }}
|
||||||
-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
|
|
||||||
@@ -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==2022.1.8",
|
||||||
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')]
|
||||||
|
|
||||||
|
|||||||
@@ -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.2'
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -383,6 +383,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 +466,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,
|
||||||
|
|||||||
+63
-26
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -506,9 +534,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
|
||||||
@@ -633,7 +662,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 +694,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,7 +875,7 @@ 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
|
||||||
@@ -914,8 +943,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 +995,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 +1010,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 +1045,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
|
||||||
@@ -1146,8 +1179,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 +1216,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 +1242,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 +1337,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
|
||||||
|
|||||||
@@ -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], 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_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
|
||||||
|
|
||||||
|
|||||||
@@ -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 *
|
||||||
@@ -364,10 +365,16 @@ class Post:
|
|||||||
@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 +396,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
|
||||||
@@ -637,6 +644,13 @@ class Post:
|
|||||||
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 +771,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',
|
||||||
@@ -957,6 +977,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 +1011,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 +1029,22 @@ 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], bool]:
|
||||||
|
newest_date: Optional[datetime] = None
|
||||||
|
def is_newest(p: Post) -> bool:
|
||||||
|
nonlocal newest_date
|
||||||
|
post_date = p.date_local
|
||||||
|
if newest_date is None or post_date > newest_date:
|
||||||
|
newest_date = post_date
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
return is_newest
|
||||||
|
|
||||||
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 +1150,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:
|
||||||
@@ -1179,7 +1230,8 @@ 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('&')
|
||||||
@@ -1552,7 +1604,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 +1629,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:
|
||||||
|
|||||||
Reference in New Issue
Block a user