mirror of
https://github.com/instaloader/instaloader.git
synced 2026-10-17 06:02:18 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27e42f661e | ||
|
|
78bbd697cd | ||
|
|
e4524e119a | ||
|
|
520b69d6ef | ||
|
|
25c4daa861 | ||
|
|
7d256c36c1 | ||
|
|
64908788d3 | ||
|
|
b7ef306374 | ||
|
|
813621026c | ||
|
|
44bd4d173b | ||
|
|
2393a1a7a4 | ||
|
|
5304e9794b | ||
|
|
ee3106814e | ||
|
|
4c6d6e50af | ||
|
|
af287c3fdd | ||
|
|
0bc9ab55dd | ||
|
|
e64394fd25 | ||
|
|
43cc15d1b3 | ||
|
|
a263ec85a6 | ||
|
|
487d2c366d | ||
|
|
e493fa9c2d | ||
|
|
2e482b063a | ||
|
|
0bc91b722e | ||
|
|
4264d40f19 | ||
|
|
b15a595083 | ||
|
|
71ae32c85e | ||
|
|
f5134ff95e |
@@ -14,11 +14,11 @@ jobs:
|
||||
- name: "Setup Python"
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
python-version: 3.14
|
||||
cache: 'pipenv'
|
||||
- name: "Install Dependencies"
|
||||
run: |
|
||||
python -m pip install pipenv==2024.2.0
|
||||
python -m pip install pipenv==2025.0.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"
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- name: Checkout Instaloader Repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -20,9 +20,10 @@ jobs:
|
||||
cache: 'pipenv'
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
python -m pip install pipenv==2024.2.0
|
||||
python -m pip install pipenv==2025.0.4
|
||||
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
|
||||
- name: PyLint
|
||||
if: matrix.python-version != '3.9'
|
||||
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run pylint instaloader
|
||||
- name: MyPy
|
||||
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run mypy -m instaloader
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: "Setup Python"
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
python-version: 3.14
|
||||
- name: "Get the tagged version"
|
||||
id: get_version
|
||||
env:
|
||||
@@ -23,11 +23,11 @@ jobs:
|
||||
run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT
|
||||
shell: bash
|
||||
- name: "Install Dependencies"
|
||||
run: python -m pip install setuptools
|
||||
run: python -m pip install -U setuptools wheel build
|
||||
- name: "Generate Python Package"
|
||||
run: python setup.py sdist
|
||||
run: python -m build
|
||||
- name: "Publish Distribution to PyPI"
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_PASSWORD }}
|
||||
- name: "Publish Distribution to AUR"
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.14"
|
||||
cache: 'pipenv'
|
||||
architecture: x64
|
||||
- name: Get the tagged version
|
||||
|
||||
@@ -433,6 +433,7 @@ disable=bad-inline-option,
|
||||
too-many-instance-attributes,
|
||||
too-many-lines,
|
||||
too-many-locals,
|
||||
too-many-positional-arguments,
|
||||
too-many-public-methods,
|
||||
too-many-statements,
|
||||
unnecessary-lambda-assignment,
|
||||
|
||||
Generated
+739
-564
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,13 @@ pkgbase = instaloader
|
||||
url = https://instaloader.github.io/
|
||||
arch = any
|
||||
license = MIT
|
||||
makedepends = python-build
|
||||
makedepends = python-installer
|
||||
makedepends = python-setuptools
|
||||
depends = python>=3.8
|
||||
depends = python-requests>=2.4
|
||||
makedepends = python-wheel
|
||||
depends = python
|
||||
depends = python-requests
|
||||
optdepends = python-browser-cookie3: Import session cookies from browser
|
||||
options = !emptydirs
|
||||
source = instaloader-{{version}}.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v{{version}}
|
||||
sha512sums = {{hash}}
|
||||
|
||||
@@ -8,20 +8,30 @@ arch=('any')
|
||||
url="https://instaloader.github.io/"
|
||||
license=('MIT')
|
||||
groups=()
|
||||
depends=('python>=3.8' 'python-requests>=2.4')
|
||||
makedepends=('python-setuptools')
|
||||
depends=(
|
||||
python
|
||||
python-requests
|
||||
)
|
||||
makedepends=(
|
||||
python-build
|
||||
python-installer
|
||||
python-setuptools
|
||||
python-wheel
|
||||
)
|
||||
optdepends=(
|
||||
'python-browser-cookie3: Import session cookies from browser'
|
||||
)
|
||||
options=('!emptydirs')
|
||||
source=($pkgname-$pkgver.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v$pkgver)
|
||||
sha512sums=('{{hash}}')
|
||||
|
||||
build() {
|
||||
cd "$srcdir/$pkgname-$pkgver"
|
||||
python setup.py clean --all
|
||||
python setup.py build
|
||||
python -m build --wheel --no-isolation
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$srcdir/$pkgname-$pkgver"
|
||||
install -Dm644 LICENSE "$pkgdir"/usr/share/licenses/$pkgname/LICENSE
|
||||
python setup.py install --root "$pkgdir/" --skip-build
|
||||
python -m installer --destdir="$pkgdir" dist/*.whl
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ with open('__main__.py', 'w+') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
# install dependencies and invoke PyInstaller
|
||||
commands = ["pip install pipenv==2024.2.0",
|
||||
commands = ["pip install pipenv==2025.0.4",
|
||||
f"pipenv --python {sys.version_info.major}.{sys.version_info.minor} sync --dev",
|
||||
"pipenv run pyinstaller --log-level=DEBUG instaloader.spec"]
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ Instaloader supports the following targets:
|
||||
to **download IGTV videos**.
|
||||
|
||||
- ``"#hashtag"``
|
||||
Posts with a certain **hashtag** (the quotes are usually necessary).
|
||||
Posts with a certain **hashtag** (the quotes are usually necessary). Requires :ref:`login<login>`.
|
||||
|
||||
- ``%location id``
|
||||
Posts tagged with a given location; the location ID is the numerical ID
|
||||
|
||||
+12
-10
@@ -208,22 +208,24 @@ Instead of :option:`--login`, it is possible to use
|
||||
|
||||
.. option:: --load-cookies BROWSER-NAME, -b BROWSER-NAME
|
||||
|
||||
Use Instagram cookie in your browser to login.
|
||||
This feature requires the browser_cookie3 library.
|
||||
Compatible with :option:`--cookiefile` if you want to load cookies from browser profiles.
|
||||
Incompatible with :option:`--login` due to potential username mismatch between user input and browser login.
|
||||
Supported browsers: Brave, Chrome, Chromium, Edge, Firefox, LibreWolf, Opera, Opera_GX, Safari and Vivaldi.
|
||||
Load Instagram session cookies from a browser instead of using Instaloader's own login mechanism.
|
||||
This method requires the `browser_cookie3 <https://github.com/borisbabic/browser_cookie3>`__ library.
|
||||
By default, cookies are loaded from the browser's main profile, but you can specify a custom cookie file using :option:`--cookiefile`.
|
||||
|
||||
In subsequent runs, you can just use :option:`--login` to reuse the
|
||||
same session, which is saved by Instaloader.
|
||||
Since :option:`--load-cookies` and :option:`--login` are alternative ways to acquire a session, they cannot be used together when initially acquiring a session.
|
||||
|
||||
Supported browsers: Arc, Brave, Chrome, Chromium, Edge, Firefox, LibreWolf, Opera, Opera_GX, Safari, and Vivaldi.
|
||||
|
||||
Once logged in via browser cookies, Instaloader saves the session, allowing you to use :option:`--login` in subsequent runs.
|
||||
|
||||
.. versionadded:: 4.11
|
||||
|
||||
.. option:: --cookiefile COOKIE-FILE, -B COOKIE-FILE
|
||||
|
||||
Cookie file path of a browser profile to load cookies from.
|
||||
Specifies a cookie file path for :option:`--load-cookies` to load cookies from a particular browser profile.
|
||||
This option must be used together with :option:`--load-cookies` and does not work on its own.
|
||||
|
||||
.. versionadded:: 4.11
|
||||
.. versionadded:: 4.11
|
||||
|
||||
.. option:: --sessionfile SESSIONFILE, -f SESSIONFILE
|
||||
|
||||
@@ -310,7 +312,7 @@ How to Download
|
||||
.. option:: --user-agent USER_AGENT
|
||||
|
||||
User Agent to use for HTTP requests. Per default, Instaloader pretends being
|
||||
Chrome/127 on Linux.
|
||||
Chrome/142 on Linux.
|
||||
|
||||
.. option:: --max-connection-attempts N
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import instaloader
|
||||
|
||||
L = instaloader.Instaloader()
|
||||
|
||||
posts = instaloader.Hashtag.from_name(L.context, 'urbanphotography').get_posts()
|
||||
posts = instaloader.Hashtag.from_name(L.context, 'urbanphotography').get_posts_resumable()
|
||||
|
||||
users = set()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import instaloader
|
||||
|
||||
L = instaloader.Instaloader()
|
||||
|
||||
posts = instaloader.Hashtag.from_name(L.context, "urbanphotography").get_posts()
|
||||
posts = instaloader.Hashtag.from_name(L.context, "urbanphotography").get_posts_resumable()
|
||||
|
||||
SINCE = datetime(2020, 5, 10) # further from today, inclusive
|
||||
UNTIL = datetime(2020, 5, 11) # closer to today, not inclusive
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Download pictures (or videos) along with their captions and other metadata from Instagram."""
|
||||
|
||||
|
||||
__version__ = '4.14'
|
||||
__version__ = '4.15.1'
|
||||
|
||||
|
||||
try:
|
||||
|
||||
@@ -112,7 +112,7 @@ def get_cookies_from_instagram(domain, browser, cookie_file='', cookie_name=''):
|
||||
print(f"Cookies loaded successfully from {browser}")
|
||||
else:
|
||||
raise LoginException(f"No cookies found for Instagram in {browser}, "
|
||||
f"Are you logged in succesfully in {browser}?")
|
||||
f"Are you logged in successfully in {browser}?")
|
||||
|
||||
if cookie_name:
|
||||
return cookies.get(cookie_name, {})
|
||||
|
||||
@@ -83,7 +83,7 @@ def _requires_login(func: Callable) -> Callable:
|
||||
|
||||
|
||||
def _retry_on_connection_error(func: Callable) -> Callable:
|
||||
"""Decorator to retry the function max_connection_attemps number of times.
|
||||
"""Decorator to retry the function max_connection_attempts number of times.
|
||||
|
||||
Herewith-decorated functions need an ``_attempt`` keyword argument.
|
||||
|
||||
@@ -737,19 +737,20 @@ class Instaloader:
|
||||
suffix: Optional[str] = str(edge_number)
|
||||
if '{filename}' in self.filename_pattern:
|
||||
suffix = None
|
||||
if self.download_pictures and (not sidecar_node.is_video or self.download_video_thumbnails):
|
||||
video_url = sidecar_node.video_url
|
||||
if self.download_pictures and (video_url is None or self.download_video_thumbnails):
|
||||
# pylint:disable=cell-var-from-loop
|
||||
sidecar_filename = self.__prepare_filename(filename_template,
|
||||
lambda: sidecar_node.display_url)
|
||||
# Download sidecar picture or video thumbnail (--no-pictures implies --no-video-thumbnails)
|
||||
downloaded &= self.download_pic(filename=sidecar_filename, url=sidecar_node.display_url,
|
||||
mtime=post.date_local, filename_suffix=suffix)
|
||||
if sidecar_node.is_video and self.download_videos:
|
||||
if video_url is not None and self.download_videos:
|
||||
# pylint:disable=cell-var-from-loop
|
||||
sidecar_filename = self.__prepare_filename(filename_template,
|
||||
lambda: sidecar_node.video_url)
|
||||
lambda: video_url)
|
||||
# Download sidecar video if desired
|
||||
downloaded &= self.download_pic(filename=sidecar_filename, url=sidecar_node.video_url,
|
||||
downloaded &= self.download_pic(filename=sidecar_filename, url=video_url,
|
||||
mtime=post.date_local, filename_suffix=suffix)
|
||||
else:
|
||||
downloaded = False
|
||||
@@ -1215,6 +1216,7 @@ class Instaloader:
|
||||
Use :meth:`Hashtag.get_posts_resumable`."""
|
||||
return Hashtag.from_name(self.context, hashtag).get_posts_resumable()
|
||||
|
||||
@_requires_login
|
||||
def download_hashtag(self, hashtag: Union[Hashtag, str],
|
||||
max_count: Optional[int] = None,
|
||||
post_filter: Optional[Callable[[Post], bool]] = None,
|
||||
@@ -1302,6 +1304,7 @@ class Instaloader:
|
||||
post_filter,
|
||||
owner_profile=profile,
|
||||
takewhile=posts_takewhile,
|
||||
possibly_pinned=3,
|
||||
)
|
||||
if latest_stamps is not None and reels.first_item is not None:
|
||||
latest_stamps.set_last_reels_timestamp(profile.username, reels.first_item.date_local)
|
||||
|
||||
@@ -32,15 +32,15 @@ def copy_session(session: requests.Session, request_timeout: Optional[float] = N
|
||||
|
||||
def default_user_agent() -> str:
|
||||
return ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36')
|
||||
'(KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36')
|
||||
|
||||
|
||||
def default_iphone_headers() -> Dict[str, Any]:
|
||||
return {'User-Agent': 'Instagram 273.0.0.16.70 (iPad13,8; iOS 16_3; en_US; en-US; ' \
|
||||
'scale=2.00; 2048x2732; 452417278) AppleWebKit/420+',
|
||||
return {'User-Agent': 'Instagram 361.0.0.35.82 (iPad13,8; iOS 18_0; en_US; en-US; ' \
|
||||
'scale=2.00; 2048x2732; 674117118) AppleWebKit/420+',
|
||||
'x-ads-opt-out': '1',
|
||||
'x-bloks-is-panorama-enabled': 'true',
|
||||
'x-bloks-version-id': '01507c21540f73e2216b6f62a11a5b5e51aa85491b72475c080da35b1228ddd6',
|
||||
'x-bloks-version-id': '16b7bd25c6c06886d57c4d455265669345a2d96625385b8ee30026ac2dc5ed97',
|
||||
'x-fb-client-ip': 'True',
|
||||
'x-fb-connection-type': 'wifi',
|
||||
'x-fb-http-engine': 'Liger',
|
||||
@@ -102,7 +102,7 @@ class InstaloaderContext:
|
||||
|
||||
self._rate_controller = rate_controller(self) if rate_controller is not None else RateController(self)
|
||||
|
||||
# Can be set to True for testing, disables supression of InstaloaderContext._error_catcher
|
||||
# Can be set to True for testing, disables suppression of InstaloaderContext._error_catcher
|
||||
self.raise_all_errors = False
|
||||
|
||||
# HTTP status codes that should cause an AbortDownloadException
|
||||
@@ -395,7 +395,10 @@ class InstaloaderContext:
|
||||
:param session: Session to use, or None to use self.session
|
||||
:param use_post: Use POST instead of GET to make the request
|
||||
:return: Decoded response dictionary
|
||||
:raises QueryReturnedBadRequestException: When the server responds with a 400.
|
||||
:raises AbortDownloadException: When the server responds with
|
||||
'feedback_required'/'checkpoint_required'/'challenge_required'
|
||||
:raises QueryReturnedBadRequestException: When the server responds with a 400 (and not
|
||||
'feedback_required'/'checkpoint_required'/'challenge_required').
|
||||
:raises QueryReturnedNotFoundException: When the server responds with a 404.
|
||||
:raises ConnectionException: When query repeatedly failed.
|
||||
|
||||
@@ -447,6 +450,15 @@ class InstaloaderContext:
|
||||
response_headers.clear()
|
||||
response_headers.update(resp.headers)
|
||||
if resp.status_code == 400:
|
||||
with suppress(json.decoder.JSONDecodeError):
|
||||
if resp.json().get("message") in [
|
||||
"feedback_required",
|
||||
"checkpoint_required",
|
||||
"challenge_required",
|
||||
]:
|
||||
# Raise AbortDownloadException in case of substantial Instagram
|
||||
# requirements to stop producing more requests
|
||||
raise AbortDownloadException(self._response_error(resp))
|
||||
raise QueryReturnedBadRequestException(self._response_error(resp))
|
||||
if resp.status_code == 404:
|
||||
raise QueryReturnedNotFoundException(self._response_error(resp))
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
|
||||
from lzma import LZMAError
|
||||
from typing import Any, Callable, Dict, Iterable, Iterator, NamedTuple, Optional, Tuple, TypeVar
|
||||
|
||||
from .exceptions import AbortDownloadException, InvalidArgumentException
|
||||
from .exceptions import InvalidArgumentException
|
||||
from .instaloadercontext import InstaloaderContext
|
||||
|
||||
class FrozenNodeIterator(NamedTuple):
|
||||
@@ -318,7 +318,7 @@ def resumable_iteration(context: InstaloaderContext,
|
||||
context.error("Warning: Not resuming from {}: {}".format(resume_file_path, exc))
|
||||
try:
|
||||
yield is_resuming, start_index
|
||||
except (KeyboardInterrupt, AbortDownloadException):
|
||||
except (Exception, KeyboardInterrupt):
|
||||
if os.path.dirname(resume_file_path):
|
||||
os.makedirs(os.path.dirname(resume_file_path), exist_ok=True)
|
||||
save(iterator.freeze(), resume_file_path)
|
||||
|
||||
+112
-34
@@ -20,7 +20,7 @@ class PostSidecarNode(NamedTuple):
|
||||
"""Item of a Sidecar Post."""
|
||||
is_video: bool
|
||||
display_url: str
|
||||
video_url: str
|
||||
video_url: Optional[str]
|
||||
|
||||
|
||||
PostSidecarNode.is_video.__doc__ = "Whether this node is a video."
|
||||
@@ -180,7 +180,7 @@ class Post:
|
||||
This class unifies access to the properties associated with a post. It implements == and is
|
||||
hashable.
|
||||
|
||||
:param context: :attr:`Instaloader.context` used for additional queries if neccessary..
|
||||
:param context: :attr:`Instaloader.context` used for additional queries if necessary..
|
||||
:param node: Node structure, as returned by Instagram.
|
||||
:param owner_profile: The Profile of the owner, if already known at creation.
|
||||
"""
|
||||
@@ -512,14 +512,14 @@ class Post:
|
||||
|
||||
@property
|
||||
def caption_hashtags(self) -> List[str]:
|
||||
"""List of all lowercased hashtags (without preceeding #) that occur in the Post's caption."""
|
||||
"""List of all lowercased hashtags (without preceding #) that occur in the Post's caption."""
|
||||
if not self.caption:
|
||||
return []
|
||||
return _hashtag_regex.findall(self.caption.lower())
|
||||
|
||||
@property
|
||||
def caption_mentions(self) -> List[str]:
|
||||
"""List of all lowercased profiles that are mentioned in the Post's caption, without preceeding @."""
|
||||
"""List of all lowercased profiles that are mentioned in the Post's caption, without preceding @."""
|
||||
if not self.caption:
|
||||
return []
|
||||
return _mention_regex.findall(self.caption.lower())
|
||||
@@ -602,6 +602,15 @@ class Post:
|
||||
return self._field('video_view_count')
|
||||
return None
|
||||
|
||||
@property
|
||||
def video_play_count(self) -> Optional[int]:
|
||||
"""Play count of the video, or None.
|
||||
|
||||
.. versionadded:: 4.14.3"""
|
||||
if self.is_video:
|
||||
return self._field('video_play_count')
|
||||
return None
|
||||
|
||||
@property
|
||||
def video_duration(self) -> Optional[float]:
|
||||
"""Duration of the video in seconds, or None.
|
||||
@@ -880,7 +889,8 @@ class Profile:
|
||||
Also, this class implements == and is hashable.
|
||||
"""
|
||||
def __init__(self, context: InstaloaderContext, node: Dict[str, Any]):
|
||||
assert 'username' in node
|
||||
assert "username" in node
|
||||
assert "id" in node or "pk" in node
|
||||
self._context = context
|
||||
self._has_public_story: Optional[bool] = None
|
||||
self._node = node
|
||||
@@ -900,10 +910,13 @@ class Profile:
|
||||
:param username: Username
|
||||
:raises: :class:`ProfileNotExistsException`
|
||||
"""
|
||||
# pylint:disable=protected-access
|
||||
profile = cls(context, {'username': username.lower()})
|
||||
profile._obtain_metadata() # to raise ProfileNotExistsException now in case username is invalid
|
||||
return profile
|
||||
data = context.doc_id_graphql_query("26347858941511777", {"hasQuery": True, "query": username})["data"]
|
||||
if data:
|
||||
for user in data["xdt_api__v1__fbsearch__non_profiled_serp"]["users"]:
|
||||
if user["username"].lower() == username.lower():
|
||||
return cls(context, user)
|
||||
|
||||
raise ProfileNotExistsException("Profile {} does not exist.".format(username))
|
||||
|
||||
@classmethod
|
||||
def from_id(cls, context: InstaloaderContext, profile_id: int):
|
||||
@@ -970,11 +983,21 @@ class Profile:
|
||||
def _obtain_metadata(self):
|
||||
try:
|
||||
if not self._has_full_metadata:
|
||||
metadata = self._context.get_iphone_json(f'api/v1/users/web_profile_info/?username={self.username}',
|
||||
params={})
|
||||
if metadata['data']['user'] is None:
|
||||
user_id = self._node.get('id') or self._node.get('pk')
|
||||
variables = {
|
||||
"id": str(user_id),
|
||||
"render_surface": "PROFILE",
|
||||
"__relay_internal__pv__PolarisCannesGuardianExperienceEnabledrelayprovider": True,
|
||||
"__relay_internal__pv__PolarisCASB976ProfileEnabledrelayprovider": False,
|
||||
"__relay_internal__pv__PolarisRepostsConsumptionEnabledrelayprovider": False,
|
||||
}
|
||||
data = self._context.doc_id_graphql_query('25980296051578533', variables)
|
||||
if data is None:
|
||||
raise QueryReturnedNotFoundException('GraphQL query returned None')
|
||||
user_data = data.get('data', {}).get('user')
|
||||
if user_data is None:
|
||||
raise ProfileNotExistsException('Profile {} does not exist.'.format(self.username))
|
||||
self._node = metadata['data']['user']
|
||||
self._node = self._normalize_profile_data(user_data)
|
||||
self._has_full_metadata = True
|
||||
except (QueryReturnedNotFoundException, KeyError) as err:
|
||||
top_search_results = TopSearchResults(self._context, self.username)
|
||||
@@ -989,6 +1012,44 @@ class Profile:
|
||||
', '.join(similar_profiles[0:5]))) from err
|
||||
raise ProfileNotExistsException('Profile {} does not exist.'.format(self.username)) from err
|
||||
|
||||
def _normalize_profile_data(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Normalize PolarisProfilePageContentQuery response to match legacy format."""
|
||||
normalized = user_data.copy()
|
||||
if 'id' not in normalized and 'pk' in normalized:
|
||||
normalized['id'] = normalized['pk']
|
||||
if 'edge_owner_to_timeline_media' not in normalized and 'media_count' in normalized:
|
||||
normalized['edge_owner_to_timeline_media'] = {'count': normalized['media_count']}
|
||||
if 'edge_felix_video_timeline' not in normalized:
|
||||
normalized['edge_felix_video_timeline'] = {'count': 0}
|
||||
if 'edge_followed_by' not in normalized and 'follower_count' in normalized:
|
||||
normalized['edge_followed_by'] = {'count': normalized['follower_count']}
|
||||
if 'edge_follow' not in normalized and 'following_count' in normalized:
|
||||
normalized['edge_follow'] = {'count': normalized['following_count']}
|
||||
if 'is_business_account' not in normalized and 'is_business' in normalized:
|
||||
normalized['is_business_account'] = normalized['is_business']
|
||||
if 'business_category_name' not in normalized and 'category' in normalized:
|
||||
normalized['business_category_name'] = normalized['category']
|
||||
friendship = normalized.get('friendship_status', {}) or {}
|
||||
if 'followed_by_viewer' not in normalized:
|
||||
normalized['followed_by_viewer'] = friendship.get('following', False)
|
||||
if 'follows_viewer' not in normalized:
|
||||
normalized['follows_viewer'] = friendship.get('followed_by', False)
|
||||
if 'blocked_by_viewer' not in normalized:
|
||||
normalized['blocked_by_viewer'] = friendship.get('blocking', False)
|
||||
if 'has_blocked_viewer' not in normalized:
|
||||
normalized['has_blocked_viewer'] = False
|
||||
if 'has_requested_viewer' not in normalized:
|
||||
normalized['has_requested_viewer'] = friendship.get('incoming_request', False)
|
||||
if 'requested_by_viewer' not in normalized:
|
||||
normalized['requested_by_viewer'] = friendship.get('outgoing_request', False)
|
||||
if 'profile_pic_url_hd' not in normalized:
|
||||
hd_info = normalized.get('hd_profile_pic_url_info')
|
||||
if hd_info and 'url' in hd_info:
|
||||
normalized['profile_pic_url_hd'] = hd_info['url']
|
||||
elif 'profile_pic_url' in normalized:
|
||||
normalized['profile_pic_url_hd'] = normalized['profile_pic_url']
|
||||
return normalized
|
||||
|
||||
def _metadata(self, *keys) -> Any:
|
||||
try:
|
||||
d = self._node
|
||||
@@ -1079,7 +1140,7 @@ class Profile:
|
||||
@property
|
||||
def biography_hashtags(self) -> List[str]:
|
||||
"""
|
||||
List of all lowercased hashtags (without preceeding #) that occur in the Profile's biography.
|
||||
List of all lowercased hashtags (without preceding #) that occur in the Profile's biography.
|
||||
|
||||
.. versionadded:: 4.10
|
||||
"""
|
||||
@@ -1090,7 +1151,7 @@ class Profile:
|
||||
@property
|
||||
def biography_mentions(self) -> List[str]:
|
||||
"""
|
||||
List of all lowercased profiles that are mentioned in the Profile's biography, without preceeding @.
|
||||
List of all lowercased profiles that are mentioned in the Profile's biography, without preceding @.
|
||||
|
||||
.. versionadded:: 4.10
|
||||
"""
|
||||
@@ -1197,18 +1258,33 @@ class Profile:
|
||||
|
||||
:rtype: NodeIterator[Post]"""
|
||||
self._obtain_metadata()
|
||||
logged_in = self._context.is_logged_in
|
||||
return NodeIterator(
|
||||
context = self._context,
|
||||
edge_extractor = lambda d: d['data']['xdt_api__v1__feed__user_timeline_graphql_connection'],
|
||||
node_wrapper = lambda n: Post.from_iphone_struct(self._context, n),
|
||||
query_variables = {'data': {
|
||||
'count': 12, 'include_relationship_info': True,
|
||||
'latest_besties_reel_media': True, 'latest_reel_media': True},
|
||||
'username': self.username},
|
||||
query_referer = 'https://www.instagram.com/{0}/'.format(self.username),
|
||||
is_first = Profile._make_is_newest_checker(),
|
||||
doc_id = '7898261790222653',
|
||||
query_hash = None,
|
||||
context=self._context,
|
||||
edge_extractor=(
|
||||
(lambda d: d["data"]["xdt_api__v1__feed__user_timeline_graphql_connection"])
|
||||
if logged_in
|
||||
else (lambda d: d["data"]["user"]["edge_owner_to_timeline_media"])
|
||||
),
|
||||
node_wrapper=(
|
||||
(lambda n: Post.from_iphone_struct(self._context, n))
|
||||
if logged_in
|
||||
else (lambda n: Post(self._context, n, self))
|
||||
),
|
||||
query_variables={
|
||||
"data": {
|
||||
"count": 12,
|
||||
"include_relationship_info": True,
|
||||
"latest_besties_reel_media": True,
|
||||
"latest_reel_media": True,
|
||||
},
|
||||
**({"username": self.username} if logged_in else {"id": self.userid}),
|
||||
},
|
||||
query_referer="https://www.instagram.com/{0}/".format(self.username),
|
||||
is_first=Profile._make_is_newest_checker(),
|
||||
doc_id="7898261790222653" if logged_in else "7950326061742207",
|
||||
query_hash=None,
|
||||
first_data=(None if logged_in else self._metadata("edge_owner_to_timeline_media")),
|
||||
)
|
||||
|
||||
def get_saved_posts(self) -> NodeIterator[Post]:
|
||||
@@ -1534,7 +1610,7 @@ class StoryItem:
|
||||
@property
|
||||
def caption_hashtags(self) -> List[str]:
|
||||
"""
|
||||
List of all lowercased hashtags (without preceeding #) that occur in the StoryItem's caption.
|
||||
List of all lowercased hashtags (without preceding #) that occur in the StoryItem's caption.
|
||||
|
||||
.. versionadded:: 4.10
|
||||
"""
|
||||
@@ -1545,7 +1621,7 @@ class StoryItem:
|
||||
@property
|
||||
def caption_mentions(self) -> List[str]:
|
||||
"""
|
||||
List of all lowercased profiles that are mentioned in the StoryItem's caption, without preceeding @.
|
||||
List of all lowercased profiles that are mentioned in the StoryItem's caption, without preceding @.
|
||||
|
||||
.. versionadded:: 4.10
|
||||
"""
|
||||
@@ -1844,11 +1920,11 @@ class Hashtag:
|
||||
@classmethod
|
||||
def from_name(cls, context: InstaloaderContext, name: str):
|
||||
"""
|
||||
Create a Hashtag instance from a given hashtag name, without preceeding '#'. Raises an Exception if there is no
|
||||
Create a Hashtag instance from a given hashtag name, without preceding '#'. Raises an Exception if there is no
|
||||
hashtag with the given name.
|
||||
|
||||
:param context: :attr:`Instaloader.context`
|
||||
:param name: Hashtag, without preceeding '#'
|
||||
:param name: Hashtag, without preceding '#'
|
||||
:raises: :class:`QueryReturnedNotFoundException`
|
||||
"""
|
||||
# pylint:disable=protected-access
|
||||
@@ -1858,11 +1934,13 @@ class Hashtag:
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Hashtag name lowercased, without preceeding '#'"""
|
||||
"""Hashtag name lowercased, without preceding '#'"""
|
||||
return self._node["name"].lower()
|
||||
|
||||
def _query(self, params):
|
||||
json_response = self._context.get_json("explore/tags/{0}/".format(self.name), params)
|
||||
json_response = self._context.get_iphone_json(
|
||||
"api/v1/tags/web_info/", {**params, "tag_name": self.name}
|
||||
)
|
||||
return json_response["graphql"]["hashtag"] if "graphql" in json_response else json_response["data"]
|
||||
|
||||
def _obtain_metadata(self):
|
||||
@@ -2185,7 +2263,7 @@ def load_structure(context: InstaloaderContext, json_structure: dict) -> JsonExp
|
||||
"""Loads a :class:`Post`, :class:`Profile`, :class:`StoryItem`, :class:`Hashtag` or :class:`FrozenNodeIterator` from
|
||||
a json structure.
|
||||
|
||||
:param context: :attr:`Instaloader.context` linked to the new object, used for additional queries if neccessary.
|
||||
:param context: :attr:`Instaloader.context` linked to the new object, used for additional queries if necessary.
|
||||
:param json_structure: Instaloader JSON structure
|
||||
|
||||
.. versionadded:: 4.8
|
||||
@@ -2215,7 +2293,7 @@ def load_structure_from_file(context: InstaloaderContext, filename: str) -> Json
|
||||
"""Loads a :class:`Post`, :class:`Profile`, :class:`StoryItem`, :class:`Hashtag` or :class:`FrozenNodeIterator` from
|
||||
a '.json' or '.json.xz' file that has been saved by :func:`save_structure_to_file`.
|
||||
|
||||
:param context: :attr:`Instaloader.context` linked to the new object, used for additional queries if neccessary.
|
||||
:param context: :attr:`Instaloader.context` linked to the new object, used for additional queries if necessary.
|
||||
:param filename: Filename, ends in '.json' or '.json.xz'
|
||||
"""
|
||||
compressed = filename.endswith('.xz')
|
||||
|
||||
@@ -63,6 +63,7 @@ setup(
|
||||
'Programming Language :: Python :: 3.11',
|
||||
'Programming Language :: Python :: 3.12',
|
||||
'Programming Language :: Python :: 3.13',
|
||||
'Programming Language :: Python :: 3.14',
|
||||
'Programming Language :: Python :: 3 :: Only',
|
||||
'Topic :: Internet',
|
||||
'Topic :: Multimedia :: Graphics'
|
||||
|
||||
Reference in New Issue
Block a user