instadroid (instagram via redroid to rss)
EXPERIMENTAL UNTIL v1.0.0
A real, logged-in Instagram Android app running in redroid (a containerised Android device), driven by uiautomator2, publishing the chronological Following feed as Atom for FreshRSS.
These docs are also a website. docs/README.md indexes the rest.
Contents
- What it does
- Host requirements
- First-time setup
- How a scrape works
- Configuration highlights
- Building and testing
- Getting help and contributing
- AI usage
- Roadmap
- License
What it does
The non-Android half ships as one image (app/): a scraper process and a feed server running side by side in the same container (see app/entrypoint.sh).
redroid (Instagram APK) <--ADB--> app: scraper (uiautomator2, every 2.5-4.5h)
|
SQLite + cropped images
|
app: feed (FastAPI -> /instagram.xml) <-- FreshRSS
docs/ARCHITECTURE.md has the component map, the trust boundaries and where state lives.
Which Android?
Instagram ships arm64 native code only, so an x86_64 host needs a redroid image with working ARM translation. erstt/redroid:13.0.0_ndk_ChromeOS (Android 13) works and is the image in docker-compose.yml; the Android 11, 14 and 15 images tried don’t, each for a different reason. One known quirk: under the default androidboot.redroid_gpu_mode=guest the Following-feed switcher doesn’t always open, so the scraper falls back to the Home feed (see Followed-accounts allowlist).
Compose refuses to start redroid on a local/data/android volume last used by a different Android major version, since mixing them corrupted system state repeatedly; give another version its own volume. docs/COMPATIBILITY.md has the full history, every tested image and Instagram build pair, and what was weighed and not shipped (host GPU mode among them), and docker compose exec app python scraper.py compat lists every pair your own database has run.
The first attempt at running redroid on the maintainer’s host also caused a kernel panic, unrelated to Instagram: two binder drivers on one kernel. docs/INCIDENTS.md has the root cause, and docs/CONTRIBUTING.md the rules to follow before running redroid on a host you haven’t personally tested it on.
Host requirements
- Docker + compose, privileged containers allowed, for redroid and the
appcontainer.appuses host networking to reach redroid’s ADB port. adbon the host.scrcpyis optional;adb exec-out screencap -p > shot.pngis enough for checks.- About 3.5GB of free RAM and a few spare cores. redroid is capped at 3g of memory with no swap (
REDROID_MEM_LIMIT) and 4 CPUs (REDROID_CPUS); the app container at 256m and 1.5 CPUs. Scrapes have peaked at up to 2.4GiB (docs/RUNLOG.md), and at the old 2g limit a run OOM-killed Android processes and froze the host (see docs/INCIDENTS.md).
First-time setup
cp .env.example .env # fill in IG_USERNAME / IG_PASSWORD (or IG_PASSWORD_FILE)
docker compose pull # a bad image tag fails here, cheaply
docker compose up -d --build # redroid boots (1-9 min; `docker ps` shows it healthy once it has)
docker compose exec app python scraper.py login # types the .env credentials into the login form
docker compose exec app python scraper.py once # first scrape, watch the output
On its first connect after each app start (a restart of redroid alone doesn’t count; run scripts/tune-android.sh then, or restart the app) the app waits for Android to finish booting and tunes the device: animations, sync and location off, the screen never sleeping, the timezone from TZ in .env if set (see “Staying under the radar”), and the unused Google/AOSP apps in app/instadroid/tune_packages.txt disabled so they never sit resident (cuts idle memory). It’s idempotent, one adb round trip; TUNE_ON_CONNECT=0 skips it, and scripts/tune-android.sh does the same by hand, e.g. after a /data/system reset.
The password doesn’t have to live in .env: IG_PASSWORD_FILE (and IG_USERNAME_FILE) read the value from a file instead, such as a Docker secret mounted at /run/secrets/ — docker-compose.yml has a commented secrets: example. FEED_TOKEN, ALERT_URL and FRESHRSS_REFRESH_URL accept a _FILE variant the same way. Setting both forms of one, or a file the app can’t read, stops the process with an error.
The app container installs Instagram on the device itself the first time it finds it missing: ensure_logged_in() fetches it with apkeep (built into the image, from APKPure) and adb install-multiples it, caching the downloaded bundle in local/data/apk so a later reinstall (e.g. after a /data/system reset — see docs/INCIDENTS.md) doesn’t re-download it. Set IG_AUTO_INSTALL=0 in .env to disable this and fall back to a manual install instead:
apkeep -a com.instagram.android -d apk-pure local
unzip -o local/com.instagram.android.xapk -d local/xapk
adb -s 127.0.0.1:5555 install-multiple local/xapk/com.instagram.android.apk local/xapk/config.*.apk
(needs apkeep on the host; apkmirror blocks scripted downloads, hence APKPure). The build installed is the version profiles’ default build (see below; scraper.py profiles shows it); IG_APK_VERSION overrides it, and latest means whatever APKPure has newest. Each pinned version is cached in its own local/data/apk/<version>/ folder. APK_CACHE_DIR/APK_FETCH_TIMEOUT tune the cache location and download/install timeout — see .env.example.
Auto-install only runs when Instagram is missing, so a newer validated build doesn’t replace an installed version by itself. The scraper picks the profile covering whatever is installed, and warns when no build of that major version has been validated. To switch, including a downgrade:
docker compose exec app python scraper.py profiles # what's available, and what each installs
docker compose exec app python scraper.py install # the default build (445.0.0.45.83)
docker compose exec app python scraper.py install 444.0.0.46.85
The saved login lives in /data and survives the replace, but an older Instagram may not accept data written by a newer one, so a downgrade can still need a fresh login.
The device tuning disables a curated list of unused system apps (app/instadroid/tune_packages.txt) to cut idle memory. One package must never be added to that list: com.android.packageinstaller. Disabling it crash-loops system_server on every later cold boot; docs/INCIDENTS.md has the measurement, the log line, and the recovery.
The scraper runs the login step at the start of every scrape, so once the session is saved on the device it is a no-op. If Instagram asks for a code or “confirm it’s you”, the run aborts with a login_screen.jpg / login_hierarchy.xml in local/data/debug and holds every later run; finish that step by hand, then scraper.py unlock (docs/OPERATIONS.md). First-run interstitials (notifications, location, “set up on new device”) are dismissed automatically. What’s specific to a range of Instagram versions (selectors, any behavior that differs, the builds checked to work, test fixtures) lives in a version profile under app/igprofiles/. A profile exists only where Instagram changed something: today that’s just v424, covering 424 (the oldest supported) onward. The scraper runs the highest profile at or below the installed version; IG_PROFILE forces one. The active profile is shown on /status and recorded in runs.selector_profile. new-profile (app/devtools/new_profile.py, installed with the dev tools; see docs/CONTRIBUTING.md) handles the mechanical work of supporting a new build: a capped capture-mode baseline run, a per-screen selector check, fixtures, validation, and a new profile only when something drifted. See docs/PROFILES.md for the design and the steps.
If a run reports no posts parsed on first screen, look at local/data/debug/last_hierarchy.xml and last_screen.jpg, then fix it in that Instagram version’s own profile directory rather than in an older one or in shared code. docker compose exec app python scraper.py dump grabs a fresh dump any time.
How a scrape works
- Log in if needed, open the target feed —
FEED_MODE=chrono(default): the real chronological Following feed, reached via the switcher (retried; cold starts are slow).FEED_MODE=home: deliberately stay on the algorithmic Home feed instead, no switcher involved at all — see “Followed-accounts allowlist” below for why you might want that. - Capture up to
MAX_STORIES_PER_RUNnot-yet-seen accounts’ current story frame from the Home feed’s tray — stories don’t appear on the Following screen, so in chrono mode this means switching to Home and back; in home mode it’s already there. See “Stories” below for what this does and doesn’t cover. - Walk the accessibility tree screen by screen. A post is registered only once the bottom of its card (share button + caption/timestamp) is on screen, so it has a stable identity. The first time an account’s own header is on screen each run, its avatar is cropped and saved (once per account, refreshed after
AVATAR_REFRESH_DAYS). - For each new post: crop the media from a screenshot (a video/Reel gets
VIDEO_SETTLE_SECONDSto let autoplay start and the audio-label overlay fade first; a carousel is swiped through in place, capturing up toMAX_CAROUSEL_SLIDES), then tap Share → “Copy link” and read the clipboard. The shortcode becomes the post id and the feed links straight to the post. If the sheet fails to open or the clipboard never updates, it’s retried on a later screen (PERMALINK_RETRIES), then the post falls back to a content hash. When a post stored that way is back on screen in a later run, Copy link is tried again and the link filled in, keeping the post’s id so readers don’t show it twice (PERMALINK_BACKFILL_PER_RUN,PERMALINK_BACKFILL_TRIES). If the caption was truncated at “… more”, its “more” span is tapped (expanding it in place, no navigation) and the fully-rendered caption is stored instead (CAPTION_EXPAND_TRIEStaps before giving up and keeping the truncated text). - Stop after
STOP_AFTER_SEENconsecutive already-stored posts orMAX_SCROLLSscreens. IfEMPTY_SCREEN_LIMITscreens in a row show no recognisable post, a debug dump (empty_feed0) is saved and the feed is reopened once; if it happens again the run stops early (empty_feed1) and/statusshows it as a warning, rather than swiping through the rest ofMAX_SCROLLSblind. - Force-stop Instagram and a short list of cached system apps (Settings, permission controller, etc.). This container’s Android never reclaims memory on its own between runs, and Instagram alone measured ~820MiB resident once opened (docs/INCIDENTS.md) — without this, that memory just sits there for the full
POLL_MIN_HOURS-POLL_MAX_HOURSgap until the next run. The saved login session lives on disk, not in the running process, so the next run’s normal login-check handles the resulting cold start the same way it always does.
Taps are always made from a hierarchy dump taken immediately beforehand, and nothing is ever tapped inside an open sheet except “Copy link” (a stray tap there could message a contact).
If a followed account renames itself, `docker compose exec app python scraper.py rename