Compare commits

..
51 Commits
Author SHA1 Message Date
André Koch-Kramer 297a88f252 Release of version 4.15.2 2026-07-05 21:16:53 +02:00
Mike Holzinger db2ae98928 Guard against null data in Post._obtain_metadata (#2705)
When Instagram returns {"data": null} for a shortcode metadata query (e.g. under
rate limiting, or for an unavailable post), response["data"]["xdt_shortcode_media"]
raised a raw TypeError before the existing `if pic_json is None` check could run.
Guard the intermediate "data" so the clean BadResponseException is raised instead,
which the download loop already handles.
2026-07-05 21:04:29 +02:00
npiriou b79a0a1e8b Fix profile metadata GraphQL query flags (#2696) 2026-07-05 20:49:03 +02:00
Alexander Graf d3cd639789 Remove unneeded symlink 2026-04-15 21:21:12 +02:00
Alexander Graf 618c23144e Welcome SocialAPIs as sponsor 2026-04-15 21:17:02 +02:00
Alexander Graf 66165a9d5d Improve typing on import_session() 2026-04-11 12:26:09 +02:00
Alexander Graf 27e42f661e Release of version 4.15.1 2026-03-21 22:01:12 +01:00
Xyfer 78bbd697cd Fix obtaining Profiles (when logged in) by migrating to new GraphQL endpoints (#2652)
Use doc_id 26347858941511777 (search query) in from_username() to get user_id
Use doc_id 25980296051578533 (PolarisProfilePageContentQuery) in _obtain_metadata()
Add _normalize_profile_data() to map new API fields to legacy format
2026-03-21 22:00:18 +01:00
Grant Nestor e4524e119a Save resume information on exception (#2600) 2026-01-18 16:46:03 +01:00
Alexander Graf 520b69d6ef Release of version 4.15 2025-11-08 22:27:37 +01:00
Alexander Graf 25c4daa861 Merge branch 'upcoming/v4.15' 2025-11-08 22:19:37 +01:00
Alexander Graf 7d256c36c1 Avoid using deprecated Hashtag.get_posts in codesnippets 2025-11-08 22:14:36 +01:00
Samuele MazziandAlexander Graf 64908788d3 Fix anonymous fetch of profile posts (#2533)
Co-authored-by: Alexander Graf <17130992+aandergr@users.noreply.github.com>
2025-11-08 22:07:58 +01:00
DenysandAlexander Graf b7ef306374 Abort download on {feedback,checkpoint,challenge}_required (#2612)
Handle Instagram feedback_required/checkpoint_required/challenge_required responses with a AbortDownloadException, causing the resumable iteration to be interrupted.

Co-authored-by: Alexander Graf <17130992+aandergr@users.noreply.github.com>
2025-11-08 18:10:47 +01:00
Alexander Graf 813621026c Update default user agent 2025-11-08 17:36:41 +01:00
Alexander Graf 44bd4d173b Update dependency versions used in CI 2025-11-08 17:33:16 +01:00
Naitri Doshi 2393a1a7a4 Add Post.video_play_count property (#2595) 2025-10-23 11:02:21 +00:00
Alexander Graf 5304e9794b First Alpha Release for Version 4.15 2025-07-18 08:06:07 +02:00
Alexander Graf ee3106814e Update default user agent 2025-07-18 08:05:11 +02:00
Alexander Graf 4c6d6e50af Update dependency versions used in CI 2025-07-18 08:05:11 +02:00
Alexander Graf af287c3fdd Merge branch 'master' into upcoming/v4.15 2025-07-18 07:51:08 +02:00
Alexander Graf 0bc9ab55dd Release of version 4.14.2 2025-07-18 07:47:19 +02:00
Ivan Semkin e64394fd25 Add possibly_pinned to download reels loop (#2539)
Add `possibly_pinned` to reels loop.
2025-07-04 07:45:07 +02:00
Alexander Graf 43cc15d1b3 Update paragraph explaining --load-cookies and --cookiefile 2025-01-29 08:09:38 +01:00
Alexander Graf a263ec85a6 Update default user agent 2025-01-29 07:52:35 +01:00
Alexander Graf 487d2c366d Update dependency versions used in CI
Changes to browser_cookie3: https://github.com/borisbabic/browser_cookie3/compare/0.19.1...0.20.1
2025-01-29 07:50:01 +01:00
master131 e493fa9c2d Update headers for iOS (#2472) 2025-01-24 08:23:49 +01:00
Alexander Graf 2e482b063a Release of version 4.14.1 2025-01-24 08:19:13 +01:00
Alexander Graf 0bc91b722e Fix download hashtags
Closes #2144.
2025-01-24 08:17:53 +01:00
tinyboxvk 4264d40f19 Fix typos (#2474)
Signed-off-by: tinyboxvk <13696594+tinyboxvk@users.noreply.github.com>
2025-01-15 08:29:32 +01:00
André Koch-Kramer b15a595083 Update .SRCINFO template for arch package 2024-11-21 08:41:30 +01:00
Alexander Graf 71ae32c85e Update PyPI and Arch packaging process 2024-11-21 08:26:05 +01:00
David Hotham f5134ff95e Publish wheels (#2430) 2024-11-21 07:58:27 +01:00
Alexander Graf 347f01b7fc Release of version 4.14 2024-11-07 08:21:03 +01:00
Alexander Graf 981eec9182 Third release candidate for version 4.14 2024-11-07 08:12:40 +01:00
Alexander Graf c54b61346e Merge branch 'master' into upcoming/v4.14 2024-11-07 08:12:00 +01:00
tinyboxvk fe9c8e1604 Bump pipenv, remove Python 3.8, add Python 3.13 (#2413) 2024-10-26 22:07:53 +02:00
tinyboxvk 524fb21b80 Update windows_exe.yml to address deprecation warnings (#2412) 2024-10-26 18:40:55 +02:00
Alexander Graf ed4add951f Second release candidate for version 4.14 2024-10-26 18:16:31 +02:00
Alexander Graf 8e6b2d83d3 Merge branch 'master' into upcoming/v4.14 2024-10-26 18:15:04 +02:00
Alexander Graf ba8faec6fe Release of version 4.13.2 2024-10-26 18:08:19 +02:00
Alexander Graf 9a74d9e109 Post._obtain_metadata: Use new XDT doc_id graphql query
Fixes #2384.
2024-10-26 18:08:19 +02:00
Alexander Graf 08f805482c First release candidate for version 4.14 2024-10-02 08:07:40 +02:00
Alexander Graf 308244b809 Merge branch 'master' into upcoming/v4.14 2024-10-02 08:06:35 +02:00
exwm b0c42a2662 feat: add --reels flag for downloading Reels videos from profiles (#2355) 2024-10-02 08:03:53 +02:00
Alexander Graf c5dacb5f6d Release of version 4.13.1 2024-08-28 08:29:57 +02:00
Eduardo Kalinowski 3a314de191 Remove rhx_gis header support (#2340)
The query used to obtain it does not work anymore.

The parameter was only used when obtaining a profile by its id, when
not logged in. This query is working fine without the special header,
so the whole functionality concerning rhx_gis was removed.

This also fixes this misleading messages logged when trying to obtain
a profile anonymously:
JSON Query to : Expecting value: line 1 column 1 (char 0)
2024-08-28 08:28:35 +02:00
Alex Chan 3a95250265 Clarify that post short codes should be preceded by a hyphen (#2343) 2024-08-28 08:27:58 +02:00
Yash Malik 2a3e5980ff minor doc update in basic-usage.rst (#2336)
(almost) nobody says "Id est". Even I, being a native English speaker, had to search for it. It should've been "i.e." in the first place. But, even that is not needed. Let's keep it simple.
2024-08-15 13:20:09 +02:00
Alexander Graf 8159229203 Update default user agent 2024-08-10 18:17:42 +02:00
Alexander Graf ae9a5efa85 Update dependencies 2024-08-10 18:17:42 +02:00
30 changed files with 1091 additions and 696 deletions
+2 -2
View File
@@ -14,11 +14,11 @@ jobs:
- name: "Setup Python" - name: "Setup Python"
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.12 python-version: 3.14
cache: 'pipenv' cache: 'pipenv'
- name: "Install Dependencies" - name: "Install Dependencies"
run: | run: |
python -m pip install pipenv==2024.0.1 python -m pip install pipenv==2025.0.4
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
- name: "Build Documentation" - name: "Build Documentation"
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run make -C docs html SPHINXOPTS="-W -n" run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run make -C docs html SPHINXOPTS="-W -n"
+3 -2
View File
@@ -9,7 +9,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
steps: steps:
- name: Checkout Instaloader Repository - name: Checkout Instaloader Repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -20,9 +20,10 @@ jobs:
cache: 'pipenv' cache: 'pipenv'
- name: Install Dependencies - name: Install Dependencies
run: | run: |
python -m pip install pipenv==2024.0.1 python -m pip install pipenv==2025.0.4
pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev pipenv --python `python --version | grep -Eo '3\.[0-9]+'` sync --dev
- name: PyLint - name: PyLint
if: matrix.python-version != '3.9'
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run pylint instaloader run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run pylint instaloader
- name: MyPy - name: MyPy
run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run mypy -m instaloader run: pipenv --python `python --version | grep -Eo '3\.[0-9]+'` run mypy -m instaloader
+4 -4
View File
@@ -15,7 +15,7 @@ jobs:
- name: "Setup Python" - name: "Setup Python"
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.12 python-version: 3.14
- name: "Get the tagged version" - name: "Get the tagged version"
id: get_version id: get_version
env: env:
@@ -23,11 +23,11 @@ jobs:
run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT
shell: bash shell: bash
- name: "Install Dependencies" - name: "Install Dependencies"
run: python -m pip install setuptools run: python -m pip install -U setuptools wheel build
- name: "Generate Python Package" - name: "Generate Python Package"
run: python setup.py sdist run: python -m build
- name: "Publish Distribution to PyPI" - name: "Publish Distribution to PyPI"
uses: pypa/gh-action-pypi-publish@master uses: pypa/gh-action-pypi-publish@release/v1
with: with:
password: ${{ secrets.PYPI_PASSWORD }} password: ${{ secrets.PYPI_PASSWORD }}
- name: "Publish Distribution to AUR" - name: "Publish Distribution to AUR"
+5 -15
View File
@@ -12,7 +12,7 @@ jobs:
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.14"
cache: 'pipenv' cache: 'pipenv'
architecture: x64 architecture: x64
- name: Get the tagged version - name: Get the tagged version
@@ -25,25 +25,15 @@ jobs:
env: env:
VERSION_TAG: v${{ steps.get_version.outputs.VERSION }} VERSION_TAG: v${{ steps.get_version.outputs.VERSION }}
run: python deploy/windows/create_exe.py run: python deploy/windows/create_exe.py
- name: Create draft release - name: Create draft release and upload EXE as release asset
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
id: create_release id: create_release
uses: actions/create-release@v1 uses: softprops/action-gh-release@v2
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tag_name: v${{ steps.get_version.outputs.VERSION }} tag_name: v${{ steps.get_version.outputs.VERSION }}
release_name: Version ${{ steps.get_version.outputs.VERSION }} name: Version ${{ steps.get_version.outputs.VERSION }}
draft: true draft: true
prerelease: contains(steps.get_version.outputs.VERSION, 'a') || contains(steps.get_version.outputs.VERSION, 'b') || contains(steps.get_version.outputs.VERSION, 'c') prerelease: contains(steps.get_version.outputs.VERSION, 'a') || contains(steps.get_version.outputs.VERSION, 'b') || contains(steps.get_version.outputs.VERSION, 'c')
- name: Upload EXE as release asset files: ./instaloader-v${{ steps.get_version.outputs.VERSION }}-windows-standalone.zip
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
id: upload-release-asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./instaloader-v${{ steps.get_version.outputs.VERSION }}-windows-standalone.zip
asset_name: instaloader-v${{ steps.get_version.outputs.VERSION }}-windows-standalone.zip
asset_content_type: application/zip
+1
View File
@@ -433,6 +433,7 @@ disable=bad-inline-option,
too-many-instance-attributes, too-many-instance-attributes,
too-many-lines, too-many-lines,
too-many-locals, too-many-locals,
too-many-positional-arguments,
too-many-public-methods, too-many-public-methods,
too-many-statements, too-many-statements,
unnecessary-lambda-assignment, unnecessary-lambda-assignment,
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Generated
+712 -537
View File
File diff suppressed because it is too large Load Diff
+21 -2
View File
@@ -53,7 +53,7 @@
:: ::
instaloader [--comments] [--geotags] instaloader [--comments] [--geotags]
[--stories] [--highlights] [--tagged] [--igtv] [--stories] [--highlights] [--tagged] [--reels] [--igtv]
[--login YOUR-USERNAME] [--fast-update] [--login YOUR-USERNAME] [--fast-update]
profile | "#hashtag" | :stories | :feed | :saved profile | "#hashtag" | :stories | :feed | :saved
@@ -124,11 +124,30 @@ Supporters
.. current-sponsors-start .. current-sponsors-start
| Instaloader is proudly sponsored by | Instaloader is proudly sponsored by
| `@rocketapi-io <https://github.com/rocketapi-io>`__
.. list-table::
:align: center
* - |socialapis|
* - `SocialAPIs <https://socialapis.io/?utm_source=github&utm_medium=sponsor&utm_campaign=instaloader>`__
|
.. list-table::
:align: center
* - `@rocketapi-io <https://github.com/rocketapi-io>`__
|
See `Alex' GitHub Sponsors <https://github.com/sponsors/aandergr>`__ page for See `Alex' GitHub Sponsors <https://github.com/sponsors/aandergr>`__ page for
how you can sponsor the development of Instaloader! how you can sponsor the development of Instaloader!
.. |socialapis| image:: ./.sponsor-logos/socialapis.png
:alt: SocialAPIs
:target: https://socialapis.io/?utm_source=github&utm_medium=sponsor&utm_campaign=instaloader
:height: 96px
.. current-sponsors-end .. current-sponsors-end
It is a pleasure for us to share our Instaloader to the world, and we are proud It is a pleasure for us to share our Instaloader to the world, and we are proud
+6 -2
View File
@@ -5,9 +5,13 @@ pkgbase = instaloader
url = https://instaloader.github.io/ url = https://instaloader.github.io/
arch = any arch = any
license = MIT license = MIT
makedepends = python-build
makedepends = python-installer
makedepends = python-setuptools makedepends = python-setuptools
depends = python>=3.8 makedepends = python-wheel
depends = python-requests>=2.4 depends = python
depends = python-requests
optdepends = python-browser-cookie3: Import session cookies from browser
options = !emptydirs options = !emptydirs
source = instaloader-{{version}}.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v{{version}} source = instaloader-{{version}}.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v{{version}}
sha512sums = {{hash}} sha512sums = {{hash}}
+15 -5
View File
@@ -8,20 +8,30 @@ arch=('any')
url="https://instaloader.github.io/" url="https://instaloader.github.io/"
license=('MIT') license=('MIT')
groups=() groups=()
depends=('python>=3.8' 'python-requests>=2.4') depends=(
makedepends=('python-setuptools') python
python-requests
)
makedepends=(
python-build
python-installer
python-setuptools
python-wheel
)
optdepends=(
'python-browser-cookie3: Import session cookies from browser'
)
options=('!emptydirs') options=('!emptydirs')
source=($pkgname-$pkgver.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v$pkgver) source=($pkgname-$pkgver.tar.gz::https://codeload.github.com/instaloader/instaloader/tar.gz/v$pkgver)
sha512sums=('{{hash}}') sha512sums=('{{hash}}')
build() { build() {
cd "$srcdir/$pkgname-$pkgver" cd "$srcdir/$pkgname-$pkgver"
python setup.py clean --all python -m build --wheel --no-isolation
python setup.py build
} }
package() { package() {
cd "$srcdir/$pkgname-$pkgver" cd "$srcdir/$pkgname-$pkgver"
install -Dm644 LICENSE "$pkgdir"/usr/share/licenses/$pkgname/LICENSE install -Dm644 LICENSE "$pkgdir"/usr/share/licenses/$pkgname/LICENSE
python setup.py install --root "$pkgdir/" --skip-build python -m installer --destdir="$pkgdir" dist/*.whl
} }
+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==2024.0.1", commands = ["pip install pipenv==2025.0.4",
f"pipenv --python {sys.version_info.major}.{sys.version_info.minor} sync --dev", f"pipenv --python {sys.version_info.major}.{sys.version_info.minor} sync --dev",
"pipenv run pyinstaller --log-level=DEBUG instaloader.spec"] "pipenv run pyinstaller --log-level=DEBUG instaloader.spec"]
+1
View File
@@ -0,0 +1 @@
../.sponsor-logos
+7 -3
View File
@@ -92,11 +92,14 @@ Instaloader supports the following targets:
- :option:`--tagged` - :option:`--tagged`
to **download posts where the user is tagged**, and to **download posts where the user is tagged**, and
- :option:`--reels`
to **download Reels videos**.
- :option:`--igtv` - :option:`--igtv`
to **download IGTV videos**. to **download IGTV videos**.
- ``"#hashtag"`` - ``"#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`` - ``%location id``
Posts tagged with a given location; the location ID is the numerical ID Posts tagged with a given location; the location ID is the numerical ID
@@ -122,7 +125,8 @@ Instaloader supports the following targets:
- ``-post`` - ``-post``
Replace **post** with the post's shortcode to download single post. Must be preceded by ``--`` in Replace **post** with the post's shortcode to download single post. Must be preceded by ``--`` in
the argument list to not be mistaken as an option flag:: the argument list to not be mistaken as an option flag. For example, to download the post
https://www.instagram.com/p/**B_K4CykAOtf**, run the command::
instaloader -- -B_K4CykAOtf instaloader -- -B_K4CykAOtf
@@ -231,7 +235,7 @@ The filter string must be a
where the attributes from :class:`Post` or where the attributes from :class:`Post` or
:class:`StoryItem` respectively are defined. :class:`StoryItem` respectively are defined.
Id est, the following attributes can be used with both The following attributes can be used with both
:option:`--post-filter` and :option:`--storyitem-filter`: :option:`--post-filter` and :option:`--storyitem-filter`:
- :attr:`~Post.owner_username` (str), :attr:`~Post.owner_id` (int) - :attr:`~Post.owner_username` (str), :attr:`~Post.owner_id` (int)
+18 -10
View File
@@ -131,6 +131,12 @@ What to Download of each Profile
.. versionadded:: 4.1 .. versionadded:: 4.1
.. option:: --reels
Also download Reels videos.
.. versionadded:: 4.14
.. option:: --igtv .. option:: --igtv
Also download IGTV videos. Also download IGTV videos.
@@ -202,22 +208,24 @@ Instead of :option:`--login`, it is possible to use
.. option:: --load-cookies BROWSER-NAME, -b BROWSER-NAME .. option:: --load-cookies BROWSER-NAME, -b BROWSER-NAME
Use Instagram cookie in your browser to login. Load Instagram session cookies from a browser instead of using Instaloader's own login mechanism.
This feature requires the browser_cookie3 library. This method requires the `browser_cookie3 <https://github.com/borisbabic/browser_cookie3>`__ library.
Compatible with :option:`--cookiefile` if you want to load cookies from browser profiles. By default, cookies are loaded from the browser's main profile, but you can specify a custom cookie file using :option:`--cookiefile`.
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.
In subsequent runs, you can just use :option:`--login` to reuse the Since :option:`--load-cookies` and :option:`--login` are alternative ways to acquire a session, they cannot be used together when initially acquiring a session.
same session, which is saved by Instaloader.
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 .. versionadded:: 4.11
.. option:: --cookiefile COOKIE-FILE, -B COOKIE-FILE .. 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 .. option:: --sessionfile SESSIONFILE, -f SESSIONFILE
@@ -304,7 +312,7 @@ How to Download
.. option:: --user-agent USER_AGENT .. option:: --user-agent USER_AGENT
User Agent to use for HTTP requests. Per default, Instaloader pretends being User Agent to use for HTTP requests. Per default, Instaloader pretends being
Chrome/126 on Linux. Chrome/142 on Linux.
.. option:: --max-connection-attempts N .. option:: --max-connection-attempts N
+1 -1
View File
@@ -2,7 +2,7 @@ import instaloader
L = instaloader.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() users = set()
@@ -3,7 +3,7 @@ import instaloader
L = instaloader.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 SINCE = datetime(2020, 5, 10) # further from today, inclusive
UNTIL = datetime(2020, 5, 11) # closer to today, not inclusive UNTIL = datetime(2020, 5, 11) # closer to today, not inclusive
+1 -1
View File
@@ -48,7 +48,7 @@ See :ref:`install` for more options on how to install Instaloader.
:: ::
instaloader [--comments] [--geotags] instaloader [--comments] [--geotags]
[--stories] [--highlights] [--tagged] [--igtv] [--stories] [--highlights] [--tagged] [--reels] [--igtv]
[--login YOUR-USERNAME] [--fast-update] [--login YOUR-USERNAME] [--fast-update]
profile | "#hashtag" | %location_id | profile | "#hashtag" | %location_id |
:stories | :feed | :saved :stories | :feed | :saved
+1 -1
View File
@@ -2,7 +2,7 @@ Exceptions
^^^^^^^^^^ ^^^^^^^^^^
.. module:: instaloader .. module:: instaloader
:noindex: :no-index:
.. highlight:: python .. highlight:: python
+1 -1
View File
@@ -2,7 +2,7 @@
^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. module:: instaloader .. module:: instaloader
:noindex: :no-index:
.. highlight:: python .. highlight:: python
+1 -1
View File
@@ -2,7 +2,7 @@
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. module:: instaloader .. module:: instaloader
:noindex: :no-index:
.. highlight:: python .. highlight:: python
+1 -1
View File
@@ -2,7 +2,7 @@ Resumable Iterations
^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^
.. module:: instaloader .. module:: instaloader
:noindex: :no-index:
.. highlight:: python .. highlight:: python
+1 -1
View File
@@ -2,7 +2,7 @@ Instagram Structures
^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^
.. module:: instaloader .. module:: instaloader
:noindex: :no-index:
.. highlight:: python .. highlight:: python
+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.13' __version__ = '4.15.2'
try: try:
+39 -20
View File
@@ -37,7 +37,7 @@ def usage_string():
argv0 = "instaloader" if argv0 == "__main__.py" else argv0 argv0 = "instaloader" if argv0 == "__main__.py" else argv0
return """ return """
{0} [--comments] [--geotags] {0} [--comments] [--geotags]
{2:{1}} [--stories] [--highlights] [--tagged] [--igtv] {2:{1}} [--stories] [--highlights] [--tagged] [--reels] [--igtv]
{2:{1}} [--login YOUR-USERNAME] [--fast-update] {2:{1}} [--login YOUR-USERNAME] [--fast-update]
{2:{1}} profile | "#hashtag" | %%location_id | :stories | :feed | :saved {2:{1}} profile | "#hashtag" | %%location_id | :stories | :feed | :saved
{0} --help""".format(argv0, len(argv0), '') {0} --help""".format(argv0, len(argv0), '')
@@ -82,7 +82,7 @@ def filterstr_to_filterfunc(filter_str: str, item_type: type):
return filterfunc return filterfunc
def get_cookies_from_instagram(domain, browser, cookie_file='', cookie_name=''): def get_cookies_from_instagram(domain: str, browser: str, cookie_file: Optional[str] = None):
supported_browsers = { supported_browsers = {
"brave": browser_cookie3.brave, "brave": browser_cookie3.brave,
"chrome": browser_cookie3.chrome, "chrome": browser_cookie3.chrome,
@@ -112,15 +112,12 @@ def get_cookies_from_instagram(domain, browser, cookie_file='', cookie_name=''):
print(f"Cookies loaded successfully from {browser}") print(f"Cookies loaded successfully from {browser}")
else: else:
raise LoginException(f"No cookies found for Instagram in {browser}, " 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
return cookies.get(cookie_name, {})
else:
return cookies
def import_session(browser, instaloader, cookiefile): def import_session(browser: str, instaloader: Instaloader, cookiefile: Optional[str]):
cookie = get_cookies_from_instagram('instagram', browser, cookiefile) cookie = get_cookies_from_instagram('instagram', browser, cookiefile)
if cookie is not None: if cookie is not None:
instaloader.context.update_cookies(cookie) instaloader.context.update_cookies(cookie)
@@ -139,6 +136,7 @@ def _main(instaloader: Instaloader, targetlist: List[str],
download_stories: bool = False, download_stories: bool = False,
download_highlights: bool = False, download_highlights: bool = False,
download_tagged: bool = False, download_tagged: bool = False,
download_reels: bool = False,
download_igtv: bool = False, download_igtv: bool = False,
fast_update: bool = False, fast_update: bool = False,
latest_stamps_file: Optional[str] = None, latest_stamps_file: Optional[str] = None,
@@ -258,11 +256,13 @@ def _main(instaloader: Instaloader, targetlist: List[str],
instaloader.download_saved_posts(fast_update=fast_update, max_count=max_count, instaloader.download_saved_posts(fast_update=fast_update, max_count=max_count,
post_filter=post_filter) post_filter=post_filter)
elif re.match(r"^[A-Za-z0-9._]+$", target): elif re.match(r"^[A-Za-z0-9._]+$", target):
download_profile_content = download_posts or download_tagged or download_reels or download_igtv
try: try:
profile = instaloader.check_profile_id(target, latest_stamps) profile = instaloader.check_profile_id(target, latest_stamps)
if instaloader.context.is_logged_in and profile.has_blocked_viewer: if instaloader.context.is_logged_in and profile.has_blocked_viewer:
if download_profile_pic or ((download_posts or download_tagged or download_igtv) if download_profile_pic or (
and not profile.is_private): download_profile_content and not profile.is_private
):
raise ProfileNotExistsException("{} blocked you; But we download her anonymously." raise ProfileNotExistsException("{} blocked you; But we download her anonymously."
.format(target)) .format(target))
else: else:
@@ -272,8 +272,7 @@ def _main(instaloader: Instaloader, targetlist: List[str],
except ProfileNotExistsException as err: except ProfileNotExistsException as err:
# Not only our profile.has_blocked_viewer condition raises ProfileNotExistsException, # Not only our profile.has_blocked_viewer condition raises ProfileNotExistsException,
# check_profile_id() also does, since access to blocked profile may be responded with 404. # check_profile_id() also does, since access to blocked profile may be responded with 404.
if instaloader.context.is_logged_in and (download_profile_pic or download_posts or if instaloader.context.is_logged_in and (download_profile_pic or download_profile_content):
download_tagged or download_igtv):
instaloader.context.log(err) instaloader.context.log(err)
instaloader.context.log("Trying again anonymously, helps in case you are just blocked.") instaloader.context.log("Trying again anonymously, helps in case you are just blocked.")
with instaloader.anonymous_copy() as anonymous_loader: with instaloader.anonymous_copy() as anonymous_loader:
@@ -297,18 +296,35 @@ def _main(instaloader: Instaloader, targetlist: List[str],
if instaloader.context.iphone_support and profiles and (download_profile_pic or download_posts) and \ if instaloader.context.iphone_support and profiles and (download_profile_pic or download_posts) and \
not instaloader.context.is_logged_in: not instaloader.context.is_logged_in:
instaloader.context.log("Hint: Login to download higher-quality versions of pictures.") instaloader.context.log("Hint: Login to download higher-quality versions of pictures.")
instaloader.download_profiles(profiles, instaloader.download_profiles(
download_profile_pic, download_posts, download_tagged, download_igtv, profiles,
download_highlights, download_stories, download_profile_pic,
fast_update, post_filter, storyitem_filter, latest_stamps=latest_stamps) download_posts,
download_tagged,
download_igtv,
download_highlights,
download_stories,
fast_update,
post_filter,
storyitem_filter,
latest_stamps=latest_stamps,
reels=download_reels,
)
if anonymous_retry_profiles: if anonymous_retry_profiles:
instaloader.context.log("Downloading anonymously: {}" instaloader.context.log("Downloading anonymously: {}"
.format(' '.join([p.username for p in anonymous_retry_profiles]))) .format(' '.join([p.username for p in anonymous_retry_profiles])))
with instaloader.anonymous_copy() as anonymous_loader: with instaloader.anonymous_copy() as anonymous_loader:
anonymous_loader.download_profiles(anonymous_retry_profiles, anonymous_loader.download_profiles(
download_profile_pic, download_posts, download_tagged, download_igtv, anonymous_retry_profiles,
fast_update=fast_update, post_filter=post_filter, download_profile_pic,
latest_stamps=latest_stamps) download_posts,
download_tagged,
download_igtv,
fast_update=fast_update,
post_filter=post_filter,
latest_stamps=latest_stamps,
reels=download_reels
)
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nInterrupted by user.", file=sys.stderr) print("\nInterrupted by user.", file=sys.stderr)
exit_code = ExitCode.USER_ABORTED exit_code = ExitCode.USER_ABORTED
@@ -409,6 +425,8 @@ def main():
help='Also download highlights of each profile that is downloaded. Requires login.') help='Also download highlights of each profile that is downloaded. Requires login.')
g_prof.add_argument('--tagged', action='store_true', g_prof.add_argument('--tagged', action='store_true',
help='Also download posts where each profile is tagged.') help='Also download posts where each profile is tagged.')
g_prof.add_argument('--reels', action='store_true',
help='Also download Reels videos.')
g_prof.add_argument('--igtv', action='store_true', g_prof.add_argument('--igtv', action='store_true',
help='Also download IGTV videos.') help='Also download IGTV videos.')
@@ -570,6 +588,7 @@ def main():
download_stories=download_stories, download_stories=download_stories,
download_highlights=args.highlights, download_highlights=args.highlights,
download_tagged=args.tagged, download_tagged=args.tagged,
download_reels=args.reels,
download_igtv=args.igtv, download_igtv=args.igtv,
fast_update=args.fast_update, fast_update=args.fast_update,
latest_stamps_file=args.latest_stamps, latest_stamps_file=args.latest_stamps,
+45 -6
View File
@@ -83,7 +83,7 @@ def _requires_login(func: Callable) -> Callable:
def _retry_on_connection_error(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. Herewith-decorated functions need an ``_attempt`` keyword argument.
@@ -737,19 +737,20 @@ class Instaloader:
suffix: Optional[str] = str(edge_number) suffix: Optional[str] = str(edge_number)
if '{filename}' in self.filename_pattern: if '{filename}' in self.filename_pattern:
suffix = None 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 # pylint:disable=cell-var-from-loop
sidecar_filename = self.__prepare_filename(filename_template, sidecar_filename = self.__prepare_filename(filename_template,
lambda: sidecar_node.display_url) lambda: sidecar_node.display_url)
# Download sidecar picture or video thumbnail (--no-pictures implies --no-video-thumbnails) # Download sidecar picture or video thumbnail (--no-pictures implies --no-video-thumbnails)
downloaded &= self.download_pic(filename=sidecar_filename, url=sidecar_node.display_url, downloaded &= self.download_pic(filename=sidecar_filename, url=sidecar_node.display_url,
mtime=post.date_local, filename_suffix=suffix) 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 # pylint:disable=cell-var-from-loop
sidecar_filename = self.__prepare_filename(filename_template, sidecar_filename = self.__prepare_filename(filename_template,
lambda: sidecar_node.video_url) lambda: video_url)
# Download sidecar video if desired # 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) mtime=post.date_local, filename_suffix=suffix)
else: else:
downloaded = False downloaded = False
@@ -1215,6 +1216,7 @@ class Instaloader:
Use :meth:`Hashtag.get_posts_resumable`.""" Use :meth:`Hashtag.get_posts_resumable`."""
return Hashtag.from_name(self.context, hashtag).get_posts_resumable() return Hashtag.from_name(self.context, hashtag).get_posts_resumable()
@_requires_login
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,
post_filter: Optional[Callable[[Post], bool]] = None, post_filter: Optional[Callable[[Post], bool]] = None,
@@ -1281,6 +1283,32 @@ class Instaloader:
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)
def download_reels(self, profile: Profile, fast_update: bool = False,
post_filter: Optional[Callable[[Post], bool]] = None,
latest_stamps: Optional[LatestStamps] = None) -> None:
"""Download reels videos of a profile.
.. versionadded:: 4.14.0
"""
self.context.log("Retrieving reels videos for profile {}.".format(profile.username))
posts_takewhile: Optional[Callable[[Post], bool]] = None
if latest_stamps is not None:
last_scraped = latest_stamps.get_last_reels_timestamp(profile.username)
posts_takewhile = lambda p: p.date_local > last_scraped
reels = profile.get_reels()
self.posts_download_loop(
reels,
profile.username,
fast_update,
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)
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,
latest_stamps: Optional[LatestStamps] = None) -> None: latest_stamps: Optional[LatestStamps] = None) -> None:
@@ -1411,7 +1439,8 @@ class Instaloader:
storyitem_filter: Optional[Callable[[Post], bool]] = None, storyitem_filter: Optional[Callable[[Post], bool]] = None,
raise_errors: bool = False, raise_errors: bool = False,
latest_stamps: Optional[LatestStamps] = None, latest_stamps: Optional[LatestStamps] = None,
max_count: Optional[int] = None): max_count: Optional[int] = None,
reels: bool = False):
"""High-level method to download set of profiles. """High-level method to download set of profiles.
:param profiles: Set of profiles to download. :param profiles: Set of profiles to download.
@@ -1429,6 +1458,7 @@ class Instaloader:
catched and printed with :meth:`InstaloaderContext.error_catcher`. catched and printed with :meth:`InstaloaderContext.error_catcher`.
:param latest_stamps: :option:`--latest-stamps`. :param latest_stamps: :option:`--latest-stamps`.
:param max_count: Maximum count of posts to download. :param max_count: Maximum count of posts to download.
:param reels: :option:`--reels`.
.. versionadded:: 4.1 .. versionadded:: 4.1
@@ -1440,6 +1470,9 @@ class Instaloader:
.. versionchanged:: 4.13 .. versionchanged:: 4.13
Add `max_count` parameter. Add `max_count` parameter.
.. versionchanged:: 4.14
Add `reels` parameter.
""" """
@contextmanager @contextmanager
@@ -1483,6 +1516,12 @@ class Instaloader:
self.download_tagged(profile, fast_update=fast_update, post_filter=post_filter, self.download_tagged(profile, fast_update=fast_update, post_filter=post_filter,
latest_stamps=latest_stamps) latest_stamps=latest_stamps)
# Download reels, if requested
if reels:
with self.context.error_catcher('Download reels of {}'.format(profile_name)):
self.download_reels(profile, fast_update=fast_update, post_filter=post_filter,
latest_stamps=latest_stamps)
# Download IGTV, if requested # Download IGTV, if requested
if igtv: if igtv:
with self.context.error_catcher('Download IGTV of {}'.format(profile_name)): with self.context.error_catcher('Download IGTV of {}'.format(profile_name)):
+24 -29
View File
@@ -1,4 +1,3 @@
import hashlib
import json import json
import os import os
import pickle import pickle
@@ -33,15 +32,15 @@ def copy_session(session: requests.Session, request_timeout: Optional[float] = N
def default_user_agent() -> str: def default_user_agent() -> str:
return ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' return ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36') '(KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36')
def default_iphone_headers() -> Dict[str, Any]: 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; ' \ return {'User-Agent': 'Instagram 361.0.0.35.82 (iPad13,8; iOS 18_0; en_US; en-US; ' \
'scale=2.00; 2048x2732; 452417278) AppleWebKit/420+', 'scale=2.00; 2048x2732; 674117118) AppleWebKit/420+',
'x-ads-opt-out': '1', 'x-ads-opt-out': '1',
'x-bloks-is-panorama-enabled': 'true', 'x-bloks-is-panorama-enabled': 'true',
'x-bloks-version-id': '01507c21540f73e2216b6f62a11a5b5e51aa85491b72475c080da35b1228ddd6', 'x-bloks-version-id': '16b7bd25c6c06886d57c4d455265669345a2d96625385b8ee30026ac2dc5ed97',
'x-fb-client-ip': 'True', 'x-fb-client-ip': 'True',
'x-fb-connection-type': 'wifi', 'x-fb-connection-type': 'wifi',
'x-fb-http-engine': 'Liger', 'x-fb-http-engine': 'Liger',
@@ -94,7 +93,6 @@ class InstaloaderContext:
self.quiet = quiet self.quiet = quiet
self.max_connection_attempts = max_connection_attempts self.max_connection_attempts = max_connection_attempts
self._graphql_page_length = 50 self._graphql_page_length = 50
self._root_rhx_gis = None
self.two_factor_auth_pending = None self.two_factor_auth_pending = None
self.iphone_support = iphone_support self.iphone_support = iphone_support
self.iphone_headers = default_iphone_headers() self.iphone_headers = default_iphone_headers()
@@ -104,7 +102,7 @@ class InstaloaderContext:
self._rate_controller = rate_controller(self) if rate_controller is not None else RateController(self) 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 self.raise_all_errors = False
# HTTP status codes that should cause an AbortDownloadException # HTTP status codes that should cause an AbortDownloadException
@@ -397,7 +395,10 @@ class InstaloaderContext:
:param session: Session to use, or None to use self.session :param session: Session to use, or None to use self.session
:param use_post: Use POST instead of GET to make the request :param use_post: Use POST instead of GET to make the request
:return: Decoded response dictionary :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 QueryReturnedNotFoundException: When the server responds with a 404.
:raises ConnectionException: When query repeatedly failed. :raises ConnectionException: When query repeatedly failed.
@@ -449,6 +450,15 @@ class InstaloaderContext:
response_headers.clear() response_headers.clear()
response_headers.update(resp.headers) response_headers.update(resp.headers)
if resp.status_code == 400: 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)) raise QueryReturnedBadRequestException(self._response_error(resp))
if resp.status_code == 404: if resp.status_code == 404:
raise QueryReturnedNotFoundException(self._response_error(resp)) raise QueryReturnedNotFoundException(self._response_error(resp))
@@ -486,15 +496,17 @@ class InstaloaderContext:
raise ConnectionException(error_string) from err raise ConnectionException(error_string) from err
def graphql_query(self, query_hash: str, variables: Dict[str, Any], def graphql_query(self, query_hash: str, variables: Dict[str, Any],
referer: Optional[str] = None, rhx_gis: Optional[str] = None) -> Dict[str, Any]: referer: Optional[str] = None) -> Dict[str, Any]:
""" """
Do a GraphQL Query. Do a GraphQL Query.
:param query_hash: Query identifying hash. :param query_hash: Query identifying hash.
:param variables: Variables for the Query. :param variables: Variables for the Query.
:param referer: HTTP Referer, or None. :param referer: HTTP Referer, or None.
:param rhx_gis: 'rhx_gis' variable as somewhere returned by Instagram, needed to 'sign' request
:return: The server's response dictionary. :return: The server's response dictionary.
.. versionchanged:: 4.13.1
Removed the `rhx_gis` parameter.
""" """
with copy_session(self._session, self.request_timeout) as tmpsession: with copy_session(self._session, self.request_timeout) as tmpsession:
tmpsession.headers.update(self._default_http_header(empty_session_only=True)) tmpsession.headers.update(self._default_http_header(empty_session_only=True))
@@ -508,12 +520,6 @@ class InstaloaderContext:
variables_json = json.dumps(variables, separators=(',', ':')) variables_json = json.dumps(variables, separators=(',', ':'))
if rhx_gis:
#self.log("rhx_gis {} query_hash {}".format(rhx_gis, query_hash))
values = "{}:{}".format(rhx_gis, variables_json)
x_instagram_gis = hashlib.md5(values.encode()).hexdigest()
tmpsession.headers['x-instagram-gis'] = x_instagram_gis
resp_json = self.get_json('graphql/query', resp_json = self.get_json('graphql/query',
params={'query_hash': query_hash, params={'query_hash': query_hash,
'variables': variables_json}, 'variables': variables_json},
@@ -559,7 +565,7 @@ class InstaloaderContext:
def graphql_node_list(self, query_hash: str, query_variables: Dict[str, Any], def graphql_node_list(self, query_hash: str, query_variables: Dict[str, Any],
query_referer: Optional[str], query_referer: Optional[str],
edge_extractor: Callable[[Dict[str, Any]], Dict[str, Any]], edge_extractor: Callable[[Dict[str, Any]], Dict[str, Any]],
rhx_gis: Optional[str] = None, _rhx_gis: Optional[str] = None,
first_data: Optional[Dict[str, Any]] = None) -> Iterator[Dict[str, Any]]: first_data: Optional[Dict[str, Any]] = None) -> Iterator[Dict[str, Any]]:
""" """
Retrieve a list of GraphQL nodes. Retrieve a list of GraphQL nodes.
@@ -571,7 +577,7 @@ class InstaloaderContext:
def _query(): def _query():
query_variables['first'] = self._graphql_page_length query_variables['first'] = self._graphql_page_length
try: try:
return edge_extractor(self.graphql_query(query_hash, query_variables, query_referer, rhx_gis)) return edge_extractor(self.graphql_query(query_hash, query_variables, query_referer))
except QueryReturnedBadRequestException: except QueryReturnedBadRequestException:
new_page_length = int(self._graphql_page_length / 2) new_page_length = int(self._graphql_page_length / 2)
if new_page_length >= 12: if new_page_length >= 12:
@@ -720,17 +726,6 @@ class InstaloaderContext:
raise QueryReturnedNotFoundException(self._response_error(resp)) raise QueryReturnedNotFoundException(self._response_error(resp))
raise ConnectionException(self._response_error(resp)) raise ConnectionException(self._response_error(resp))
@property
def root_rhx_gis(self) -> Optional[str]:
"""rhx_gis string returned in the / query."""
if self.is_logged_in:
# At the moment, rhx_gis seems to be required for anonymous requests only. By returning None when logged
# in, we can save the root_rhx_gis lookup query.
return None
if self._root_rhx_gis is None:
self._root_rhx_gis = self.get_json('', {}).get('rhx_gis', '')
return self._root_rhx_gis or None
class RateController: class RateController:
""" """
+9
View File
@@ -18,6 +18,7 @@ class LatestStamps:
POST_TIMESTAMP = 'post-timestamp' POST_TIMESTAMP = 'post-timestamp'
TAGGED_TIMESTAMP = 'tagged-timestamp' TAGGED_TIMESTAMP = 'tagged-timestamp'
IGTV_TIMESTAMP = 'igtv-timestamp' IGTV_TIMESTAMP = 'igtv-timestamp'
REELS_TIMESTAMP = 'reels-timestamp'
STORY_TIMESTAMP = 'story-timestamp' STORY_TIMESTAMP = 'story-timestamp'
ISO_FORMAT = '%Y-%m-%dT%H:%M:%S.%f%z' ISO_FORMAT = '%Y-%m-%dT%H:%M:%S.%f%z'
@@ -87,6 +88,14 @@ class LatestStamps:
"""Sets timestamp of last download of a profile's tagged posts.""" """Sets timestamp of last download of a profile's tagged posts."""
self._set_timestamp(profile_name, self.TAGGED_TIMESTAMP, timestamp) self._set_timestamp(profile_name, self.TAGGED_TIMESTAMP, timestamp)
def get_last_reels_timestamp(self, profile_name: str) -> datetime:
"""Returns timestamp of last download of a profile's reels posts."""
return self._get_timestamp(profile_name, self.REELS_TIMESTAMP)
def set_last_reels_timestamp(self, profile_name: str, timestamp: datetime):
"""Sets timestamp of last download of a profile's reels posts."""
self._set_timestamp(profile_name, self.REELS_TIMESTAMP, timestamp)
def get_last_igtv_timestamp(self, profile_name: str) -> datetime: def get_last_igtv_timestamp(self, profile_name: str) -> datetime:
"""Returns timestamp of last download of a profile's igtv posts.""" """Returns timestamp of last download of a profile's igtv posts."""
return self._get_timestamp(profile_name, self.IGTV_TIMESTAMP) return self._get_timestamp(profile_name, self.IGTV_TIMESTAMP)
+2 -2
View File
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
from lzma import LZMAError from lzma import LZMAError
from typing import Any, Callable, Dict, Iterable, Iterator, NamedTuple, Optional, Tuple, TypeVar 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 from .instaloadercontext import InstaloaderContext
class FrozenNodeIterator(NamedTuple): class FrozenNodeIterator(NamedTuple):
@@ -318,7 +318,7 @@ def resumable_iteration(context: InstaloaderContext,
context.error("Warning: Not resuming from {}: {}".format(resume_file_path, exc)) context.error("Warning: Not resuming from {}: {}".format(resume_file_path, exc))
try: try:
yield is_resuming, start_index yield is_resuming, start_index
except (KeyboardInterrupt, AbortDownloadException): except (Exception, KeyboardInterrupt):
if os.path.dirname(resume_file_path): if os.path.dirname(resume_file_path):
os.makedirs(os.path.dirname(resume_file_path), exist_ok=True) os.makedirs(os.path.dirname(resume_file_path), exist_ok=True)
save(iterator.freeze(), resume_file_path) save(iterator.freeze(), resume_file_path)
+161 -42
View File
@@ -20,7 +20,7 @@ class PostSidecarNode(NamedTuple):
"""Item of a Sidecar Post.""" """Item of a Sidecar Post."""
is_video: bool is_video: bool
display_url: str display_url: str
video_url: str video_url: Optional[str]
PostSidecarNode.is_video.__doc__ = "Whether this node is a video." 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 This class unifies access to the properties associated with a post. It implements == and is
hashable. 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 node: Node structure, as returned by Instagram.
:param owner_profile: The Profile of the owner, if already known at creation. :param owner_profile: The Profile of the owner, if already known at creation.
""" """
@@ -319,13 +319,25 @@ class Post:
def _obtain_metadata(self): def _obtain_metadata(self):
if not self._full_metadata_dict: if not self._full_metadata_dict:
pic_json = self._context.graphql_query( response = self._context.doc_id_graphql_query(
'2b0673e0dc4580674a88d426fe00ea90', "8845758582119845", {"shortcode": self.shortcode}
{'shortcode': self.shortcode}
) )
self._full_metadata_dict = pic_json['data']['shortcode_media'] data = (response or {}).get("data")
if self._full_metadata_dict is None: pic_json = data.get("xdt_shortcode_media") if data else None
if pic_json is None:
raise BadResponseException("Fetching Post metadata failed.") raise BadResponseException("Fetching Post metadata failed.")
try:
xdt_types = {
"XDTGraphImage": "GraphImage",
"XDTGraphVideo": "GraphVideo",
"XDTGraphSidecar": "GraphSidecar",
}
pic_json["__typename"] = xdt_types[pic_json["__typename"]]
except KeyError as exc:
raise BadResponseException(
f"Unknown __typename in metadata: {pic_json['__typename']}."
) from exc
self._full_metadata_dict = pic_json
if self.shortcode != self._full_metadata_dict['shortcode']: if self.shortcode != self._full_metadata_dict['shortcode']:
self._node.update(self._full_metadata_dict) self._node.update(self._full_metadata_dict)
raise PostChangedException raise PostChangedException
@@ -502,14 +514,14 @@ class Post:
@property @property
def caption_hashtags(self) -> List[str]: 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: if not self.caption:
return [] return []
return _hashtag_regex.findall(self.caption.lower()) return _hashtag_regex.findall(self.caption.lower())
@property @property
def caption_mentions(self) -> List[str]: 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: if not self.caption:
return [] return []
return _mention_regex.findall(self.caption.lower()) return _mention_regex.findall(self.caption.lower())
@@ -592,6 +604,15 @@ class Post:
return self._field('video_view_count') return self._field('video_view_count')
return None 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 @property
def video_duration(self) -> Optional[float]: def video_duration(self) -> Optional[float]:
"""Duration of the video in seconds, or None. """Duration of the video in seconds, or None.
@@ -734,7 +755,11 @@ class Post:
# Avoid doing additional requests if there are no comments # Avoid doing additional requests if there are no comments
return [] return []
comment_edges = self._field('edge_media_to_comment', 'edges') try:
comment_edges = self._field("edge_media_to_parent_comment", "edges")
except KeyError:
comment_edges = self._field("edge_media_to_comment", "edges")
answers_count = sum(edge['node'].get('edge_threaded_comments', {}).get('count', 0) for edge in comment_edges) answers_count = sum(edge['node'].get('edge_threaded_comments', {}).get('count', 0) for edge in comment_edges)
if self.comments == len(comment_edges) + answers_count: if self.comments == len(comment_edges) + answers_count:
@@ -866,7 +891,8 @@ class Profile:
Also, this class implements == and is hashable. Also, this class implements == and is hashable.
""" """
def __init__(self, context: InstaloaderContext, node: Dict[str, Any]): 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._context = context
self._has_public_story: Optional[bool] = None self._has_public_story: Optional[bool] = None
self._node = node self._node = node
@@ -886,10 +912,13 @@ class Profile:
:param username: Username :param username: Username
:raises: :class:`ProfileNotExistsException` :raises: :class:`ProfileNotExistsException`
""" """
# pylint:disable=protected-access data = context.doc_id_graphql_query("26347858941511777", {"hasQuery": True, "query": username})["data"]
profile = cls(context, {'username': username.lower()}) if data:
profile._obtain_metadata() # to raise ProfileNotExistsException now in case username is invalid for user in data["xdt_api__v1__fbsearch__non_profiled_serp"]["users"]:
return profile if user["username"].lower() == username.lower():
return cls(context, user)
raise ProfileNotExistsException("Profile {} does not exist.".format(username))
@classmethod @classmethod
def from_id(cls, context: InstaloaderContext, profile_id: int): def from_id(cls, context: InstaloaderContext, profile_id: int):
@@ -908,8 +937,7 @@ class Profile:
'include_reel': True, 'include_reel': True,
'include_suggested_users': False, 'include_suggested_users': False,
'include_logged_out_extras': False, 'include_logged_out_extras': False,
'include_highlight_reels': False}, 'include_highlight_reels': False})['data']['user']
rhx_gis=context.root_rhx_gis)['data']['user']
if data: if data:
profile = cls(context, data['reel']['owner']) profile = cls(context, data['reel']['owner'])
else: else:
@@ -957,11 +985,23 @@ 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_iphone_json(f'api/v1/users/web_profile_info/?username={self.username}', user_id = self._node.get('id') or self._node.get('pk')
params={}) variables = {
if metadata['data']['user'] is None: "id": str(user_id),
"render_surface": "PROFILE",
"__relay_internal__pv__PolarisCannesGuardianExperienceEnabledrelayprovider": True,
"__relay_internal__pv__PolarisCASB976ProfileEnabledrelayprovider": False,
"__relay_internal__pv__PolarisRepostsConsumptionEnabledrelayprovider": False,
"__relay_internal__pv__PolarisWebSchoolsEnabledrelayprovider": False,
"enable_integrity_filters": True,
}
data = self._context.doc_id_graphql_query('27937681195819736', 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)) 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 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)
@@ -976,6 +1016,44 @@ class Profile:
', '.join(similar_profiles[0:5]))) from err ', '.join(similar_profiles[0:5]))) from err
raise ProfileNotExistsException('Profile {} does not exist.'.format(self.username)) 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: def _metadata(self, *keys) -> Any:
try: try:
d = self._node d = self._node
@@ -1066,7 +1144,7 @@ class Profile:
@property @property
def biography_hashtags(self) -> List[str]: 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 .. versionadded:: 4.10
""" """
@@ -1077,7 +1155,7 @@ class Profile:
@property @property
def biography_mentions(self) -> List[str]: 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 .. versionadded:: 4.10
""" """
@@ -1184,18 +1262,33 @@ class Profile:
:rtype: NodeIterator[Post]""" :rtype: NodeIterator[Post]"""
self._obtain_metadata() self._obtain_metadata()
logged_in = self._context.is_logged_in
return NodeIterator( return NodeIterator(
context = self._context, context=self._context,
edge_extractor = lambda d: d['data']['xdt_api__v1__feed__user_timeline_graphql_connection'], edge_extractor=(
node_wrapper = lambda n: Post.from_iphone_struct(self._context, n), (lambda d: d["data"]["xdt_api__v1__feed__user_timeline_graphql_connection"])
query_variables = {'data': { if logged_in
'count': 12, 'include_relationship_info': True, else (lambda d: d["data"]["user"]["edge_owner_to_timeline_media"])
'latest_besties_reel_media': True, 'latest_reel_media': True}, ),
'username': self.username}, node_wrapper=(
query_referer = 'https://www.instagram.com/{0}/'.format(self.username), (lambda n: Post.from_iphone_struct(self._context, n))
is_first = Profile._make_is_newest_checker(), if logged_in
doc_id = '7898261790222653', else (lambda n: Post(self._context, n, self))
query_hash = None, ),
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]: def get_saved_posts(self) -> NodeIterator[Post]:
@@ -1232,6 +1325,30 @@ class Profile:
is_first=Profile._make_is_newest_checker() is_first=Profile._make_is_newest_checker()
) )
def get_reels(self) -> NodeIterator[Post]:
"""Retrieve all reels from a profile.
:rtype: NodeIterator[Post]
.. versionadded:: 4.14.0
"""
self._obtain_metadata()
return NodeIterator(
context = self._context,
edge_extractor = lambda d: d['data']['xdt_api__v1__clips__user__connection_v2'],
# Reels post info is incomplete relative to regular posts so we create a Post from the shortcode
# and fetch the additional metadata with an additional API request per Reel
node_wrapper = lambda n: Post.from_shortcode(context=self._context, shortcode=n["media"]["code"]),
query_variables = {'data': {
'page_size': 12, 'include_feed_video': True, "target_user_id": str(self.userid)}},
query_referer = 'https://www.instagram.com/{0}/'.format(self.username),
is_first = Profile._make_is_newest_checker(),
# fb_api_req_friendly_name=PolarisProfileReelsTabContentQuery_connection
doc_id = '7845543455542541',
query_hash = None,
)
def get_igtv_posts(self) -> NodeIterator[Post]: def get_igtv_posts(self) -> NodeIterator[Post]:
"""Retrieve all IGTV posts. """Retrieve all IGTV posts.
@@ -1497,7 +1614,7 @@ class StoryItem:
@property @property
def caption_hashtags(self) -> List[str]: 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 .. versionadded:: 4.10
""" """
@@ -1508,7 +1625,7 @@ class StoryItem:
@property @property
def caption_mentions(self) -> List[str]: 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 .. versionadded:: 4.10
""" """
@@ -1807,11 +1924,11 @@ class Hashtag:
@classmethod @classmethod
def from_name(cls, context: InstaloaderContext, name: str): 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. hashtag with the given name.
:param context: :attr:`Instaloader.context` :param context: :attr:`Instaloader.context`
:param name: Hashtag, without preceeding '#' :param name: Hashtag, without preceding '#'
:raises: :class:`QueryReturnedNotFoundException` :raises: :class:`QueryReturnedNotFoundException`
""" """
# pylint:disable=protected-access # pylint:disable=protected-access
@@ -1821,11 +1938,13 @@ class Hashtag:
@property @property
def name(self): def name(self):
"""Hashtag name lowercased, without preceeding '#'""" """Hashtag name lowercased, without preceding '#'"""
return self._node["name"].lower() return self._node["name"].lower()
def _query(self, params): 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"] return json_response["graphql"]["hashtag"] if "graphql" in json_response else json_response["data"]
def _obtain_metadata(self): def _obtain_metadata(self):
@@ -2148,7 +2267,7 @@ def load_structure(context: InstaloaderContext, json_structure: dict) -> JsonExp
"""Loads a :class:`Post`, :class:`Profile`, :class:`StoryItem`, :class:`Hashtag` or :class:`FrozenNodeIterator` from """Loads a :class:`Post`, :class:`Profile`, :class:`StoryItem`, :class:`Hashtag` or :class:`FrozenNodeIterator` from
a json structure. 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 :param json_structure: Instaloader JSON structure
.. versionadded:: 4.8 .. versionadded:: 4.8
@@ -2178,7 +2297,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 """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`. 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' :param filename: Filename, ends in '.json' or '.json.xz'
""" """
compressed = filename.endswith('.xz') compressed = filename.endswith('.xz')
+6 -5
View File
@@ -18,10 +18,10 @@ def get_version():
raise SystemExit("Could not find version string.") raise SystemExit("Could not find version string.")
if sys.version_info < (3, 8): if sys.version_info < (3, 9):
sys.exit('Instaloader requires Python >= 3.8.') sys.exit('Instaloader requires Python >= 3.9.')
requirements = ['requests>=2.4'] requirements = ['requests>=2.25']
optional_requirements = { optional_requirements = {
'browser_cookie3': ['browser_cookie3>=0.19.1'], 'browser_cookie3': ['browser_cookie3>=0.19.1'],
} }
@@ -46,7 +46,7 @@ setup(
'from Instagram.', 'from Instagram.',
long_description=open(os.path.join(SRC, 'README.rst')).read(), long_description=open(os.path.join(SRC, 'README.rst')).read(),
install_requires=requirements, install_requires=requirements,
python_requires='>=3.8', python_requires='>=3.9',
extras_require=optional_requirements, extras_require=optional_requirements,
entry_points={'console_scripts': ['instaloader=instaloader.__main__:main']}, entry_points={'console_scripts': ['instaloader=instaloader.__main__:main']},
zip_safe=False, zip_safe=False,
@@ -58,11 +58,12 @@ setup(
'Intended Audience :: Developers', 'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License', 'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent', 'Operating System :: OS Independent',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.12',
'Programming Language :: Python :: 3.13',
'Programming Language :: Python :: 3.14',
'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3 :: Only',
'Topic :: Internet', 'Topic :: Internet',
'Topic :: Multimedia :: Graphics' 'Topic :: Multimedia :: Graphics'