A local-only, personal-use tool that counts how many Instagram Reels you watched on any given day by reading Instagram's built-in Watch History screen via Android UI automation. It stores daily totals in a local SQLite database and visualises them as an interactive, GitHub-style heatmap dashboard served on your machine.
⚠️ DisclaimerThis is an unofficial, personal-use tool and is not affiliated with, endorsed by, or connected to Instagram or Meta in any way. Automating the Instagram app may violate Instagram's Terms of Use. Use at your own risk — the authors take no responsibility for any action taken against your account. The tool is strictly read-only (it only reads Watch History; it never likes, follows, posts, comments, or sends DMs), which minimises risk, but risk is not zero.
| Phase | What it covers | Status |
|---|---|---|
| Phase A | Storage layer (SQLite + JSONL), dashboard (serve), CLI (run, backfill, report, export, doctor), test suite |
Working — no emulator required for the dashboard; use seed-demo or --mock-count for dev/demo. |
| Phase B | Live Android emulator scraper (drives Instagram via uiautomator2) | Working — implemented and verified end-to-end. run/backfill start the emulator automatically, navigate to Watch History, apply a single-day date filter, and count reels via perceptual-hash de-duplication. |
Android emulator (AVD)
└─ uiautomator2 → instagram_nav.py → watch_history.py
│
▼
ScrapeResult (models.py)
│
┌───────────────┴────────────────┐
▼ ▼
SQLite (canonical) JSONL audit trail
data/reel_tracker.sqlite data/*.jsonl
│
┌─────────┴──────────┐
▼ ▼
Dashboard CSV / JSON
(serve) (exports/)
The scraper drives a pre-configured Android emulator, opens the Instagram Watch History screen, applies a single-day date filter, and scrolls through the grid counting reels via perceptual-hash de-duplication. Counts come from the in-app Watch History — the authoritative record with ~30-day retention. Instagram's "Download Your Information" export is intentionally not used because it proved incomplete and under-reports consumption.
SQLite is the canonical source of truth. JSONL files are append-only event logs (audit trail; never modified). CSV and JSON exports are derived from SQLite and regenerated after every successful run.
For domain terminology see CONTEXT.md. For design decisions see docs/adr/.
- Today can't be counted. Instagram's date filter is end-exclusive and its picker won't select tomorrow, so a day can only be counted once it's complete. Run for
yesterday(the default) — that's the earliest countable day. - 30-day retention window. Watch History only retains ~30 days of data. Run daily (or use
backfillover a range) to capture history before it ages out. Once a count is in SQLite, it's kept forever. - Heavy usage means slower runs. The scraper sees ~9 reels per scroll, so hundreds of reels in a day means a multi-minute scrape. If runs consistently come back at "medium" confidence (cap hit), raise
MAX_SCROLLSin.env. - UI-automation fragility. Selectors target Instagram's current Android UI and may need updating when the app changes. Pinning the Instagram APK version can help.
By default this project stores no personally identifying information about the content you watched:
- No author handles, Reel captions, URLs, or screenshots are ever written to disk.
- Each Watch Entry is reduced to a non-reversible, salted fingerprint (SHA-256 over handle + timestamp + caption snippet, or a perceptual image hash). The fingerprint detects if the same card appears twice while scrolling but cannot be reversed into the original content.
- The
STORE_CARD_METADATAflag defaults tofalse. Changing it totrueis opt-in and requires a deliberate edit to.env.
What stays on your machine and never gets committed:
| File / dir | Role |
|---|---|
data/reel_tracker.sqlite |
Canonical source of truth — daily counts + observation fingerprints. |
data/run_log.jsonl |
Append-only audit trail of every run (start, success/failure, metadata). |
data/daily_counts_history.jsonl |
Append-only event log for daily counts. |
data/reel_observations.jsonl |
Append-only event log for individual fingerprints. |
exports/*.csv, exports/*.json |
Convenience exports derived from SQLite; regenerated each run. |
debug_runs/ |
Screenshots and hierarchy dumps captured on scraper failure. |
.env |
Your local config (AVD name, timezone, paths). Never committed. |
All of the above are gitignored. If you use Instagram's "Download Your Information" feature for comparison purposes, keep those files outside the repo directory.
Prerequisites: Python 3.11+, git.
Primary / tested platform: macOS.
# 1. Clone and enter the project
git clone <repo-url> Reel-Heatmap
cd Reel-Heatmap
# 2. Create and activate a virtual environment
python3 -m venv .venv
source .venv/bin/activate
# 3. Install dependencies (includes this package in editable mode)
pip install -r requirements.txt
# 4. Configure environment
cp .env.example .env
# Edit .env to set your LOCAL_TIMEZONE and, for Phase B, ANDROID_HOME and AVD_NAME.# Confirm the environment looks good
python -m ig_reel_tracker doctor
# Populate 60 days of sample data so the dashboard has something to show
# (DEV ONLY — uses random fake counts, not real data)
python -m ig_reel_tracker seed-demo --days 60
# Open the heatmap dashboard in your browser (http://127.0.0.1:8787)
python -m ig_reel_tracker serve
# Print the last 30 days of counts in the terminal
python -m ig_reel_tracker report --last 30
# Write convenience CSV/JSON exports to ./exports/
python -m ig_reel_tracker exportThe run command drives a real Android emulator to read your Watch History. Set this up once:
-
Install Android Studio (includes the Android SDK): https://developer.android.com/studio
-
Add tools to your PATH. Set
ANDROID_HOME(the SDK root) and add bothplatform-toolsandemulatordirectories to your shell'sPATH, then reload your shell:# Example for zsh — add to ~/.zshrc export ANDROID_HOME="$HOME/Library/Android/sdk" export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"
-
Create a Google Play-enabled AVD. Open Android Studio → Device Manager → Create Virtual Device. Choose a Pixel model (e.g. Pixel 8) with a Google Play system image (API 34 recommended — labelled "Google Play", not "Google APIs"). Name it
Pixel_8_API_34(or updateAVD_NAMEin.env). -
Start the emulator once and log in to Instagram manually:
emulator -avd Pixel_8_API_34
Inside the running emulator: open the Play Store, install Instagram, launch it, and sign in with your account. Navigate to Profile → Menu (≡) → Your activity → Watch history to confirm it exists.
-
Close the emulator. The login session is now saved inside the AVD's virtual disk.
Important — never wipe the AVD. The login session persists in the AVD's app data across automated runs. Wiping the AVD (e.g.
emulator -avd ... -wipe-data) destroys the session and requires a manual re-login. The scraper never passes-wipe-data. Seedocs/adr/0001-emulator-over-real-device.md.
After setup, python -m ig_reel_tracker doctor should show the AVD as available. run (without --mock-count) will start the emulator automatically, scrape Watch History, and shut it down when done (controlled by SHUTDOWN_EMULATOR_AFTER_RUN).
python -m ig_reel_tracker doctor
Checks the local environment: Python version, database file, data directories, adb/emulator availability (if ANDROID_HOME is set), and the configured AVD.
python -m ig_reel_tracker run [--date DATE] [--visible | --headless]
[--open-dashboard] [--mock-count N]
| Flag | Description |
|---|---|
--date DATE |
today, yesterday, or YYYY-MM-DD (default: yesterday) |
--visible |
Force the emulator to run with a window |
--headless |
Force the emulator to run headless |
--open-dashboard |
Start the dashboard after the run completes |
--mock-count N |
DEV ONLY: skip the emulator and store N fake reels — exercises the storage pipeline without requiring an AVD |
Note:
--date todayis rejected — see Limitations. Real data requires the emulator; omit--mock-countfor a live scrape.
python -m ig_reel_tracker backfill --start YYYY-MM-DD --end YYYY-MM-DD [--mock-count N]
Counts every date in the range in order. Dates older than the Watch History retention window (~30 days) are skipped with a warning. Today is also skipped (for the same reason as run). --mock-count is DEV ONLY.
python -m ig_reel_tracker report [--last N]
Prints a table of the most recent N daily counts (default: 30) from the local database.
python -m ig_reel_tracker export
Writes exports/daily_counts.csv and exports/daily_counts.json derived from the SQLite database.
python -m ig_reel_tracker serve [--host HOST] [--port PORT] [--no-open]
Starts the local dashboard at http://127.0.0.1:8787 by default. The --no-open flag suppresses automatic browser launch.
Security note: the dashboard binds to
127.0.0.1only (localhost). If you overrideWEB_HOSTin.envto a non-loopback address, anyone on your network can reach it — there is no authentication.
python -m ig_reel_tracker seed-demo [--days N]
Populates N days of plausible random daily counts (default: 60). Useful for trying the dashboard before any real data exists. This writes fake counts — not real Instagram data.
serve starts a Flask server that listens only on 127.0.0.1:8787 (never on a public interface). Open http://127.0.0.1:8787 in your browser.
The dashboard shows:
- Heatmap — a calendar grid coloured by daily Reel count (darker = more watched).
- Summary cards — total, average, peak, and most recent counts.
- Runs table — history of scraper runs with status, confidence, and source.
- Trend chart — rolling view of daily counts over time.
- Export links — download the CSV/JSON exports directly from the browser.
If the default port is occupied: python -m ig_reel_tracker serve --port 8788.
Reel-Heatmap/
├── .env.example # Copy to .env and fill in
├── CONTEXT.md # Domain glossary (Watch Entry, Fingerprint, etc.)
├── LICENSE
├── pyproject.toml
├── requirements.txt
│
├── src/ig_reel_tracker/
│ ├── __init__.py
│ ├── __main__.py # `python -m ig_reel_tracker` entry point
│ ├── append_log.py # JSONL append helpers
│ ├── cli.py # CLI parser and run orchestration
│ ├── config.py # Config dataclass loaded from .env
│ ├── date_utils.py # Target-date resolution, timezone helpers
│ ├── diagnostics.py # `doctor` command checks
│ ├── emulator.py # AVD lifecycle manager
│ ├── errors.py # Project-specific exception types
│ ├── exports.py # CSV/JSON export writer
│ ├── fingerprint.py # Salted SHA-256 + perceptual dHash
│ ├── instagram_nav.py # AndroidDevice + navigation + date filter
│ ├── models.py # Observation, ScrapeResult dataclasses
│ ├── storage.py # SQLite schema + query helpers
│ ├── watch_history.py # scrape_watch_history + counter
│ ├── web.py # Flask dashboard server
│ ├── web_static/
│ │ ├── app.js
│ │ └── styles.css
│ └── web_templates/
│ └── index.html
│
├── tests/
│ ├── test_append_log.py
│ ├── test_date_utils.py
│ ├── test_fingerprint.py
│ ├── test_storage.py
│ └── test_web_api.py
│
├── data/ # Created on first run (gitignored)
│ ├── reel_tracker.sqlite
│ ├── run_log.jsonl
│ ├── daily_counts_history.jsonl
│ └── reel_observations.jsonl
│
├── exports/ # CSV/JSON exports (gitignored)
├── debug_runs/ # Screenshots/hierarchy dumps on failure (gitignored)
└── docs/
└── adr/
├── 0001-emulator-over-real-device.md
└── 0002-total-watch-events-over-distinct-reels.md
pytestThe test suite covers storage, fingerprinting, date utilities, the JSONL append log, and the web API. All tests run without an emulator.
Key variables in .env (see .env.example for the full list):
| Variable | Default | Description |
|---|---|---|
AVD_NAME |
Pixel_8_API_34 |
Name of the Android Virtual Device to use. |
ANDROID_HOME |
(none) | Path to the Android SDK root directory. Required for Phase B. |
LOCAL_TIMEZONE |
Australia/Sydney |
IANA timezone for resolving today/yesterday. |
DB_PATH |
./data/reel_tracker.sqlite |
SQLite database path. |
HEADLESS |
false |
Run emulator headless by default. |
MAX_SCROLLS |
150 |
Safety cap on scrolls per run (~9 reels/scroll). Raise for heavy usage. |
SCROLL_PAUSE_SECONDS |
0.7 |
Pause between scrolls; reduce only if the device is consistently fast. |
SHUTDOWN_EMULATOR_AFTER_RUN |
true |
Shut down the emulator after a run (only if this run started it). |
FINGERPRINT_SALT |
(blank) | Salt for one-way fingerprints. Keep stable; changing it breaks comparisons. |
WEB_HOST |
127.0.0.1 |
Dashboard bind host. Do not change unless you understand the security implications. |
WEB_PORT |
8787 |
Dashboard port. |
STORE_CARD_METADATA |
false |
Opt-in to storing handles/captions. Keep false for privacy. |
| Problem | Fix |
|---|---|
Address already in use when running serve |
Use a different port: serve --port 8788 |
| Instagram shows a login screen during a run | Open the emulator manually (emulator -avd <name>), log in, then rerun. |
Watch History not found in the app |
Ensure Instagram is updated to the latest version from the Play Store; confirm the menu path exists: Profile → ≡ → Your activity → Watch history. |
| Run returns "medium" confidence | The scroll cap was hit before reaching end-of-list. Raise MAX_SCROLLS in .env. |
adb: command not found |
Add $ANDROID_HOME/platform-tools to your PATH and reload your shell. |
emulator: command not found |
Add $ANDROID_HOME/emulator to your PATH and reload your shell. |
ANDROID_HOME is not set |
Add export ANDROID_HOME="$HOME/Library/Android/sdk" (or your SDK root) to your shell profile. |
Issues and PRs are welcome. This is a personal project maintained on a best-effort basis, so response times may vary. When filing a bug, include the output of python -m ig_reel_tracker doctor and the relevant lines from data/run_log.jsonl (redact anything personal).
MIT — see LICENSE.