instagrab
A Rust CLI that scrapes Instagram profile metadata (full name, biography, follower / following / post counts, recent posts with captions) by attaching to a real Chrome process over the Chrome DevTools Protocol.
Designed to run on a server you own under cron, alongside a long-lived headless Chrome whose user-data-dir holds an authenticated IG session.
It can scan a fixed list of profiles, or fan out over everyone a seed profile follows (re-fetched on demand), writing one JSONL record + the first image per post into a directory.
Instagram's ToS prohibits automated scraping. Use at your own account risk. The defaults (daily cadence, jittered inter-profile sleeps, single account) are conservative for personal-scale use.
Getting started
You'll need a recent Rust toolchain and Chrome.
# 1. Launch Chrome with a dedicated dev profile and sign in to IG manually:
# 2. Build and write a sample config, then edit it
# (at minimum set output_path and seed_username):
# 3. Fetch your follows list (merges into <output_path>/friends.txt):
# 4. Scan everyone on that list (writes <output_path>/runs.jsonl + images):
|
instagrab does two things:
--fetch-followsmerges your seed profile's Following into a follows file you can edit (run it rarely).- The default run scans everyone in that file for recent posts + first image (run it daily).
For more information, see Friends fan-out and Configuration.
Output shape
A successful run emits one JSON object per username:
source is "graphql" (the only successful kind), "not_found",
"logged_out", or "error". Per-field problems land in the errors array.
window_maybe_truncated: true means paging stopped before reaching the
oldest post in the window; bump max_scrolls if you need to go further.
A post may also carry media_count, is_carousel, and has_video (present
only when it's a carousel or a video). Due to rate limiting, only the first image is downloaded;
one can link out to Instagram for the rest.
Friends fan-out
instagrab's main mode scans everyone a seed profile follows, re-fetching that list on demand.
instagrab --fetch-follows pages seed_username's Following once, at a human
pace, and merges the handles into the follows file
(<output_path>/friends.txt). This is the only path that touches Instagram's
friendship endpoint (isolated in src/follows.rs); it is off by default and
detection-sensitive, so run it rarely. It prints progress
(fetching follows... N so far) and a summary of what the merge changed.
--seed <name> overrides seed_username ad hoc.
The follows file is yours to edit
The harvest merges; it does not overwrite. Your edits survive it, so the file — not Instagram — is the durable record of what gets scraped.
There's one username per line, and a commented-out username is an exclusion: still followed, just never scanned.
# accounts I follow but don't want on the wall
anniebannie
# nononancy <- excluded; the harvest leaves this comment alone
berryp
Across a --fetch-follows:
| On file | Still followed? | Result |
|---|---|---|
anniebannie |
yes | kept verbatim |
# nononancy |
yes | kept verbatim — exclusion preserved |
berryp |
no | dropped |
# oldfriend |
no | dropped |
| — | yes, and new | appended |
Blank lines and multi-word comments are preserved exactly where they sit. A
comment holding a lone username-shaped word is read as an exclusion, so keep
header comments to more than one word — # archived would be mistaken for a
handle, # archived accounts would not.
Deletions are withheld whenever the harvest might be short. Absence from a
truncated list isn't evidence of an unfollow, and one rate-limited page would
otherwise prune most of the file. The merge stays additive when the fetch
errored (e.g. an HTTP 429 mid-pagination) or when a clean harvest came back with
under half the entries already on file; either way it emits a follows_partial
alert and deletes nothing.
Commands
| Command | What it does |
|---|---|
instagrab |
Scan everyone in the follows file (the daily job). |
instagrab --fetch-follows [--seed <name>] |
Merge the seed's Following into the follows file, keeping your edits (run rarely). |
instagrab --once <name> |
Scan a single profile, ignoring the follows list. |
instagrab --limit <n> |
Scan at most N profiles from the follows list (order stalest-first upstream to scan the oldest N). |
instagrab --dry-run |
Connect to Chrome and exit — a connection test. |
instagrab --write-sample-config <path> |
Write a starter config and exit. |
instagrab --debug |
Add per-profile fetch/DOM debug output on stderr. |
instagrab --config <path> |
Use a specific config (default /etc/instagrab/config.toml). |
Configuration
All settings live in the TOML file passed with --config (generate a starter
with --write-sample-config). output_path (an output directory) is
required; everything else has a default.
| Key | Default | Meaning |
|---|---|---|
browser_url |
http://127.0.0.1:9222 |
CDP endpoint of the long-lived Chrome instagrab attaches to. |
output_path |
— (required) | The one output directory. Holds runs.jsonl, images/, and friends.txt. |
seed_username |
— | Profile whose Following --fetch-follows pages (or pass --seed). |
time_window_days |
0 (no filter) |
Keep only posts within the last N days; > 0 also drives feed pagination. |
jitter_min_secs |
30 |
Minimum inter-profile sleep, seconds (anti-detection). |
jitter_max_secs |
75 |
Maximum inter-profile sleep, seconds. |
nav_timeout_sec |
20 |
Per-profile navigation/wait budget, seconds. |
max_scrolls |
8 |
Safety cap on feed pages fetched per profile. |
usernames |
[] |
Fixed scrape list, used only as a fallback when the follows file is empty. |
The canary scrape (against instagram.com/instagram) has no knob — it always
runs first and exits 5 if extraction looks broken.
Alerts
Out-of-band conditions surface as event: "alert" lines and a non-zero exit:
| Kind | Meaning | Exit |
|---|---|---|
logged_out |
IG demanded login or redirected to /accounts/login |
2 |
schema_drift |
web_profile_info parsed but expected fields are missing on every profile this run |
3 |
follows_partial |
A --fetch-follows harvest looked truncated, so the merge deleted nothing |
0 |
logged_out means: re-run the SSH-tunnel login bootstrap (see deploy/README.md).
schema_drift means: IG renamed/removed a JSON path; update
EXPECTED_PROFILE_PATHS and the parser in src/parse.rs.
follows_partial is not a failure — the follows file is intact and simply a
merge behind. It exits 0 (or 4, if the fetch returned nothing at all).
Deploying
See deploy/README.md for a full deployment walkthrough,
including the SSH-tunnel login bootstrap that mints cookies
(so IG's first-login flag fires once, during setup, not during cron).
How it works
- A persistent
--headless=newChrome runs on the host with--remote-debugging-port=9222 --user-data-dir=…/CDPProfile. You log in to Instagram once into that profile. instagrabconnects to that Chrome via CDP, navigates each profile in its own tab, then actively fetches/api/v1/users/web_profile_info/from inside the page (with theX-IG-App-IDheader IG's web app sends). Passively listening for that endpoint isn't reliable — the logged-in React shell doesn't always trigger it.- Posts come from the mobile-API user-feed endpoint
(
/api/v1/feed/user/<id>/), fetched from inside the page and paged viamax_id. Iftime_window_days > 0, instagrab keeps paging until the oldest captured post is older than the window. - Each kept post's
display_urlis downloaded over plain HTTPS to<output_path>/images/<username>/<shortcode>.jpg(idempotent — existing files are kept). - One JSONL line per username is appended to
<output_path>/runs.jsonl.