Compare commits

..
20 Commits
Author SHA1 Message Date
Alexander Graf d938f3e2f5 Third Beta Release for Version 4.9 2022-03-19 16:35:53 +01:00
Alexander Graf 374d9d0d81 Merge branch 'fully-migrate-to-gh-actions' into upcoming/v4.9 2022-03-19 16:34:59 +01:00
Alexander Graf 3cbee1b318 Fully Migrate to GitHub Actions 2022-03-19 16:29:48 +01:00
Alexander Graf dbc91cbeb1 Release of Version 4.8.5 2022-03-19 15:08:36 +01:00
fireattack 5a42a7537f Use header to determine file extension (#1447) 2022-03-19 15:04:42 +01:00
MiguelX413 d8d268f861 Mention regex will now be restricted to ASCII (#1456) 2022-03-18 14:57:28 +01:00
MiguelX413 463ada519e Add unicode normalization of captions (#1453) 2022-03-17 16:06:26 +01:00
canh 604b107586 Add --sanitize-paths option (#1452) 2022-03-17 15:27:36 +01:00
Alexander Graf 37a93ee59a Second Beta Release for Version 4.9 2022-01-15 17:34:18 +01:00
2d4f3b8537 Make {Post,StoryItem}.date_local timezone aware
This commit is made up of 21365ec668 555c86633c d864ce08ff

Discussed in #1305, #1316, #1372.

Co-Authored-By: Misael <misael.om@gmail.com>
Co-Authored-By: Eduardo Kalinowski <eduardo@kalinowski.com.br>
2022-01-15 17:34:18 +01:00
Alexander Graf 8c752532e1 Merge branch 'master' into upcoming/v4.9 2022-01-15 17:17:19 +01:00
Alexander Graf fdcf2e1367 Release of Version 4.8.4 2022-01-15 17:11:45 +01:00
Alexander Graf ca78fee307 Revert date_utc and date_local being timezone aware
Fixes #1379.

Revert "Make {Post,StoryItem}.date_utc timezone aware"
d864ce08ff.

Revert "Remove unnecessary conversion to timezone aware timestamp (#1372)"
555c86633c.

Revert "Timezone option for datetime style formatting (#1316)"
21365ec668.
2022-01-15 17:08:04 +01:00
Alexander Graf d6e5e31005 Release of Version 4.8.3 2022-01-07 14:56:40 +01:00
Alexander Graf d864ce08ff Make {Post,StoryItem}.date_utc timezone aware
Also simplify {Post,StoryItem}.date_local.

Discussed in #1316.
2022-01-07 14:53:23 +01:00
Eduardo Kalinowski 555c86633c Remove unnecessary conversion to timezone aware timestamp (#1372)
Post.date_local now already returns a timestamp with the appropriate
timezone.
2022-01-07 14:31:41 +01:00
Misael 21365ec668 Timezone option for datetime style formatting (#1316)
Add timezone awareness for datetime objects returned by `date_local` and `date_utc`.
Fixes #1305.
2021-12-31 16:50:16 +01:00
Alexander Graf 6343e9a69d Release of Version 4.8.2 2021-11-27 16:25:12 +01:00
Alexander Graf 06574eb428 Fix KeyError on attempt to get incomplete location
Fixes #1349.
2021-11-27 16:25:12 +01:00
JeremyKj87 815ba83d77 avoid comments endless loop - check if query_response is always the same (#1347) 2021-11-27 15:47:32 +01:00
13 changed files with 188 additions and 102 deletions
+31
View File
@@ -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.GITHUB_TOKEN }}
+2 -7
View File
@@ -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"
+40
View File
@@ -0,0 +1,40 @@
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: "Install Dependencies"
run: |
python -m pip install pipenv==2022.1.8
pipenv sync --dev
- 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
View File
@@ -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
+3 -2
View File
@@ -3,15 +3,16 @@
VERSION=${1:1} VERSION=${1:1}
# do not deploy pre-releases # do not deploy pre-releases
echo $VERSION | grep -qe "[abc]" && exit 0 #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
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.
+1 -1
View File
@@ -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"]
+7
View File
@@ -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
+1 -1
View File
@@ -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.9b1' __version__ = '4.9b3'
try: try:
+5 -1
View File
@@ -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,
+64 -31
View File
@@ -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
@@ -458,9 +486,12 @@ class Instaloader:
def save_location(self, filename: str, location: PostLocation, mtime: datetime) -> None: def save_location(self, filename: str, location: PostLocation, mtime: datetime) -> None:
"""Save post location name and Google Maps link.""" """Save post location name and Google Maps link."""
filename += '_location.txt' filename += '_location.txt'
location_string = (location.name + "\n" + if location.lat is not None and location.lng is not None:
"https://maps.google.com/maps?q={0},{1}&ll={0},{1}\n".format(location.lat, location_string = (location.name + "\n" +
location.lng)) "https://maps.google.com/maps?q={0},{1}&ll={0},{1}\n".format(location.lat,
location.lng))
else:
location_string = location.name
with open(filename, 'wb') as text_file: with open(filename, 'wb') as text_file:
with BytesIO(location_string.encode()) as bio: with BytesIO(location_string.encode()) as bio:
shutil.copyfileobj(cast(IO, bio), text_file) shutil.copyfileobj(cast(IO, bio), text_file)
@@ -503,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
@@ -630,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:
""" """
@@ -662,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)
@@ -813,7 +845,7 @@ class Instaloader:
last_scraped = latest_stamps.get_last_story_timestamp(name) last_scraped = latest_stamps.get_last_story_timestamp(name)
scraped_timestamp = datetime.now().astimezone() scraped_timestamp = datetime.now().astimezone()
for item in user_story.get_items(): for item in user_story.get_items():
if latest_stamps is not None and item.date_utc.replace(tzinfo=timezone.utc) <= last_scraped: if latest_stamps is not None and item.date_local <= last_scraped:
break break
if storyitem_filter is not None and not storyitem_filter(item): if storyitem_filter is not None and not storyitem_filter(item):
self.context.log("<{} skipped>".format(item), flush=True) self.context.log("<{} skipped>".format(item), flush=True)
@@ -843,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
@@ -911,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
@@ -962,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(
@@ -1202,15 +1235,15 @@ class Instaloader:
posts_takewhile: Optional[Callable[[Post], bool]] = None posts_takewhile: Optional[Callable[[Post], bool]] = None
if latest_stamps is not None: if latest_stamps is not None:
last_scraped = latest_stamps.get_last_tagged_timestamp(profile.username) last_scraped = latest_stamps.get_last_tagged_timestamp(profile.username)
posts_takewhile = lambda p: p.date_utc.replace(tzinfo=timezone.utc) > last_scraped posts_takewhile = lambda p: p.date_local > last_scraped
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.astimezone()) latest_stamps.set_last_tagged_timestamp(profile.username, tagged_posts.first_item.date_local)
def download_igtv(self, profile: Profile, fast_update: bool = False, def download_igtv(self, profile: Profile, fast_update: bool = False,
post_filter: Optional[Callable[[Post], bool]] = None, post_filter: Optional[Callable[[Post], bool]] = None,
@@ -1225,12 +1258,12 @@ class Instaloader:
posts_takewhile: Optional[Callable[[Post], bool]] = None posts_takewhile: Optional[Callable[[Post], bool]] = None
if latest_stamps is not None: if latest_stamps is not None:
last_scraped = latest_stamps.get_last_igtv_timestamp(profile.username) last_scraped = latest_stamps.get_last_igtv_timestamp(profile.username)
posts_takewhile = lambda p: p.date_utc.replace(tzinfo=timezone.utc) > last_scraped posts_takewhile = lambda p: p.date_local > last_scraped
igtv_posts = profile.get_igtv_posts() igtv_posts = profile.get_igtv_posts()
self.posts_download_loop(igtv_posts, profile.username, fast_update, post_filter, self.posts_download_loop(igtv_posts, profile.username, fast_update, post_filter,
total_count=profile.igtvcount, owner_profile=profile, takewhile=posts_takewhile) total_count=profile.igtvcount, owner_profile=profile, takewhile=posts_takewhile)
if latest_stamps is not None and igtv_posts.first_item is not None: if latest_stamps is not None and igtv_posts.first_item is not None:
latest_stamps.set_last_igtv_timestamp(profile.username, igtv_posts.first_item.date_local.astimezone()) latest_stamps.set_last_igtv_timestamp(profile.username, igtv_posts.first_item.date_local)
def _get_id_filename(self, profile_name: str) -> str: def _get_id_filename(self, profile_name: str) -> str:
if ((format_string_contains_key(self.dirname_pattern, 'profile') or if ((format_string_contains_key(self.dirname_pattern, 'profile') or
@@ -1423,14 +1456,14 @@ class Instaloader:
if latest_stamps is not None: if latest_stamps is not None:
# pylint:disable=cell-var-from-loop # pylint:disable=cell-var-from-loop
last_scraped = latest_stamps.get_last_post_timestamp(profile_name) last_scraped = latest_stamps.get_last_post_timestamp(profile_name)
posts_takewhile = lambda p: p.date_utc.replace(tzinfo=timezone.utc) > last_scraped posts_takewhile = lambda p: p.date_local > last_scraped
posts_to_download = profile.get_posts() posts_to_download = profile.get_posts()
self.posts_download_loop(posts_to_download, profile_name, fast_update, post_filter, self.posts_download_loop(posts_to_download, profile_name, fast_update, post_filter,
total_count=profile.mediacount, owner_profile=profile, total_count=profile.mediacount, owner_profile=profile,
takewhile=posts_takewhile) takewhile=posts_takewhile)
if latest_stamps is not None and posts_to_download.first_item is not None: if latest_stamps is not None and posts_to_download.first_item is not None:
latest_stamps.set_last_post_timestamp(profile_name, latest_stamps.set_last_post_timestamp(profile_name,
posts_to_download.first_item.date_local.astimezone()) posts_to_download.first_item.date_local)
if stories and profiles: if stories and profiles:
with self.context.error_catcher("Download stories"): with self.context.error_catcher("Download stories"):
+9 -8
View File
@@ -133,14 +133,15 @@ class NodeIterator(Iterator[T]):
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'])
page_index, data = self._page_index, self._data if self._data['edges'] != query_response['edges']:
try: page_index, data = self._page_index, self._data
self._page_index = 0 try:
self._data = query_response self._page_index = 0
except KeyboardInterrupt: self._data = query_response
self._page_index, self._data = page_index, data except KeyboardInterrupt:
raise self._page_index, self._data = page_index, data
return self.__next__() raise
return self.__next__()
raise StopIteration() raise StopIteration()
@property @property
+25 -16
View File
@@ -8,6 +8,7 @@ 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, Dict, Iterable, Iterator, List, Optional, Tuple, Union
from unicodedata import normalize
from . import __version__ from . import __version__
from .exceptions import * from .exceptions import *
@@ -38,8 +39,8 @@ PostLocation.id.__doc__ = "ID number of location."
PostLocation.name.__doc__ = "Location name." PostLocation.name.__doc__ = "Location name."
PostLocation.slug.__doc__ = "URL friendly variant of location name." PostLocation.slug.__doc__ = "URL friendly variant of location name."
PostLocation.has_public_page.__doc__ = "Whether location has a public page." PostLocation.has_public_page.__doc__ = "Whether location has a public page."
PostLocation.lat.__doc__ = "Latitude (:class:`float`)." PostLocation.lat.__doc__ = "Latitude (:class:`float` or None)."
PostLocation.lng.__doc__ = "Longitude (:class:`float`)." PostLocation.lng.__doc__ = "Longitude (:class:`float` or None)."
class Post: class Post:
@@ -262,17 +263,16 @@ class Post:
@property @property
def date_local(self) -> datetime: def date_local(self) -> datetime:
"""Timestamp when the post was created (local time zone).""" """Timestamp when the post was created (local time zone).
return datetime.fromtimestamp(self._node["date"]
if "date" in self._node .. versionchanged:: 4.9
else self._node["taken_at_timestamp"]) Return timezone aware datetime object."""
return datetime.fromtimestamp(self._get_timestamp_date_created()).astimezone()
@property @property
def date_utc(self) -> datetime: def date_utc(self) -> datetime:
"""Timestamp when the post was created (UTC).""" """Timestamp when the post was created (UTC)."""
return datetime.utcfromtimestamp(self._node["date"] return datetime.utcfromtimestamp(self._get_timestamp_date_created())
if "date" in self._node
else self._node["taken_at_timestamp"])
@property @property
def date(self) -> datetime: def date(self) -> datetime:
@@ -313,6 +313,12 @@ class Post:
return len(edges) return len(edges)
return 1 return 1
def _get_timestamp_date_created(self) -> float:
"""Timestamp when the post was created"""
return (self._node["date"]
if "date" in self._node
else self._node["taken_at_timestamp"])
def get_is_videos(self) -> List[bool]: def get_is_videos(self) -> List[bool]:
""" """
Return a list containing the ``is_video`` property for each media in the post. Return a list containing the ``is_video`` property for each media in the post.
@@ -360,9 +366,9 @@ class Post:
def caption(self) -> Optional[str]: def caption(self) -> Optional[str]:
"""Caption.""" """Caption."""
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("NFC", 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("NFC", self._node["caption"])
return None return None
@property @property
@@ -384,7 +390,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
@@ -629,7 +635,7 @@ class Post:
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})['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['lat'], loc['lng']) loc.get('lat'), loc.get('lng'))
return self._location return self._location
@@ -1140,8 +1146,11 @@ class StoryItem:
@property @property
def date_local(self) -> datetime: def date_local(self) -> datetime:
"""Timestamp when the StoryItem was created (local time zone).""" """Timestamp when the StoryItem was created (local time zone).
return datetime.fromtimestamp(self._node['taken_at_timestamp'])
.. versionchanged:: 4.9
Return timezone aware datetime object."""
return datetime.fromtimestamp(self._node['taken_at_timestamp']).astimezone()
@property @property
def date_utc(self) -> datetime: def date_utc(self) -> datetime:
@@ -1655,7 +1664,7 @@ class TopSearchResults:
place = location.get('place', {}) place = location.get('place', {})
slug = place.get('slug') slug = place.get('slug')
loc = place.get('location', {}) loc = place.get('location', {})
yield PostLocation(int(loc['pk']), loc['name'], slug, None, loc['lat'], loc['lng']) yield PostLocation(int(loc['pk']), loc['name'], slug, None, loc.get('lat'), loc.get('lng'))
def get_hashtag_strings(self) -> Iterator[str]: def get_hashtag_strings(self) -> Iterator[str]:
""" """