The parser behind the Instagram report at opt2in.com,
published as the one file it is: report.js, 4554 lines, no
dependencies, and byte-for-byte the file the site serves at
https://opt2in.com/report/instagram/report.js on the day of this build
(2026-10-01; SHA-256 4a00584925a6842f1fb4da0e2d05999eb02c6d289ac95d38e91b8341d472a7a6).
It takes the ZIP that Meta hands you when you download your Instagram information and turns it into a plain JavaScript object: who advertised to you and how often, what you were shown, what Meta says it knows about you, what you did. The site renders that object as a report. This repository holds the parsing, and only the parsing.
Scope, stated once. The report's rendering, the offer matching, the backend functions, the account and admin surfaces, and everything else that makes up the site are not here and are not open source. This is a trust artifact, not a community project: it exists so that a claim about your data can be checked instead of believed. CONTRIBUTING.md says what that means for pull requests (in short: bug reports yes, merges no).
Two checks, which prove two different things.
report.js contains no call to fetch, XMLHttpRequest, sendBeacon or
WebSocket, no require or import, and, outside comments, no reference to
navigator, localStorage or cookies. Reproduce it (the second grep drops
comment lines, because the file's own comments discuss what it refuses to do):
# Call sites, comments excluded. Expected output: nothing.
grep -nE '\b(fetch|XMLHttpRequest|sendBeacon|WebSocket|EventSource)\s*\(|\bnew\s+(XMLHttpRequest|WebSocket|EventSource|Worker|Image)\b|\b(require|import)\s*\(|^\s*import\s|\b(navigator|localStorage|sessionStorage|indexedDB|cookie)\b' report.js \
| grep -vE '^[0-9]+:\s*(//|\*|/\*)'
# Every mention of the network words, comments included. Expected output at
# the time of this build: 1 line(s), each a comment.
grep -nwE 'fetch|XMLHttpRequest|sendBeacon|WebSocket|EventSource' report.jsAt the time of this build the second search returned:
3940:// with NO network fetch (fetching would broadcast click history off-device
The only browser API the file touches is DOMParser, for the HTML variant
of the export, and that use is guarded so the file also runs under Node,
which is how the test suite runs it. tests/smoke.test.js pins the same
searches as assertions.
The parser making no network calls is the narrow, provable claim. It does not follow that nothing leaves your browser when you use the site, and the site does not claim that either. Its privacy policy says, in the section headed "Your raw export never leaves your device":
When you upload your data export, it's unzipped, parsed, and turned into your report entirely on your device. The raw file (your messages, photos, the whole ZIP) is never uploaded. Open your browser's Network tab while the report builds and watch. You'll see a small ping that a report ran, or, if your file could not be turned into a report, one saying it was refused and why, in a short fixed label; a second one counting how many offers matched, a request for our own offers file, brand logos loading from DuckDuckGo unless you've switched them off at the top of the report, and, if you are signed in, the automatic save described just below. That is the whole list.
Read the full text at https://opt2in.com/privacy.html; it also names what the DuckDuckGo logo requests carry (the advertiser domain names from your report, and your IP address) and what the Locations map (Apple) receives if you open it. Two things in that list are worth repeating here because this repository cannot vouch for them:
- the code that makes those requests (the logo loader, the counters, the offers file, the map) lives in the site's page and render layer, which is outside this repository;
- a saved report is uploaded and stored. If you are signed in when a report finishes, the analyzed report (the counts, the company names and the value math, never your messages, photos or the export file) is saved to your account. Run the report signed out and the save does not happen.
So the check that this repository supports is the first one: read the file. The check that covers the whole page is the second one: watch the Network tab, and compare what you see to the list the policy publishes.
curl -s https://opt2in.com/report/instagram/report.js | diff - report.js && echo identical
curl -s https://opt2in.com/report/instagram/report.js | sha256sumThe site deploys on every change; this repository is refreshed deliberately.
The site may run a newer version than the repository tip. A diff that
shows changes means the repository is behind, not that something is hidden:
the served file is the authoritative one, it is readable in full in your
browser, and the checks above apply to it just the same. .gitattributes
forces LF line endings so that the diff is byte-exact on any operating
system.
extractAll(files) runs 37 extractors, one per section of
the report, and returns an object with 39 top-level
keys. The table is generated from the code at build time: the key each
extractor fills, the files it reads (paths relative to the export root), and
whether it can also read Meta's HTML export.
| Key | Extractor | Reads | HTML export |
|---|---|---|---|
window |
extractWindow |
your_instagram_activity/other_activity/your_information_download_requests.json |
partial |
identity |
extractIdentity |
personal_information/autofill_information/autofill_information.html, personal_information/autofill_information/autofill_information.json, personal_information/personal_information/personal_information.html, personal_information/personal_information/personal_information.json, security_and_login_information/login_and_profile_creation/signup_details.html, security_and_login_information/login_and_profile_creation/signup_details.json |
yes |
identity (merged, from instagram_profile_information) |
extractInstagramProfileInfo |
personal_information/personal_information/instagram_profile_information.json |
no |
identity (merged, from autofill) |
extractAutofillFull |
personal_information/autofill_information/autofill_information.json |
no |
export_request |
extractDownloadRequest |
your_instagram_activity/other_activity/your_information_download_requests.json |
no |
checkout_profile |
extractCheckoutProfile |
your_instagram_activity/shopping/checkout_payment_information.json |
no |
cart_items |
extractCartItems |
your_instagram_activity/shopping/cart_items.json |
no |
device_summary |
extractDeviceSummary |
personal_information/device_information/devices.json |
no |
device_fingerprint |
extractDeviceFingerprint |
personal_information/device_information/camera_information.json |
no |
location |
extractLocation |
personal_information/information_about_you/locations_of_interest.json, personal_information/information_about_you/profile_based_in.json, security_and_login_information/login_and_profile_creation/last_known_location.json |
no |
audience |
extractAudience |
logged_information/past_instagram_insights/audience_insights.html, logged_information/past_instagram_insights/audience_insights.json |
yes |
content_interactions |
extractContentInteractions |
logged_information/past_instagram_insights/content_interactions.json |
no |
profiles_reached |
extractProfilesReached |
logged_information/past_instagram_insights/profiles_reached.json |
no |
ai_interests |
extractAIInterests |
your_instagram_activity/ai/interest_categories.html, your_instagram_activity/ai/interest_categories.json |
yes |
topics |
extractTopics |
preferences/your_topics/recommended_topics.html, preferences/your_topics/recommended_topics.json |
yes |
ad_prefs |
extractAdPreferences |
ads_information/instagram_ads_and_businesses/ad_preferences.json |
no |
other_categories |
extractOtherCategories |
ads_information/instagram_ads_and_businesses/other_categories_used_to_reach_you.json |
no |
ads_about_meta |
extractAdsAboutMeta |
ads_information/instagram_ads_and_businesses/ads_about_meta.json |
no |
advertisers |
extractAdvertisers |
ads_information/instagram_ads_and_businesses/advertisers_using_your_activity_or_information.html, ads_information/instagram_ads_and_businesses/advertisers_using_your_activity_or_information.json |
yes |
off_meta |
extractOffMeta |
apps_and_websites_off_of_instagram/apps_and_websites/your_activity_off_meta_technologies.json |
yes |
link_history |
extractLinkHistory |
logged_information/link_history/link_history.json |
no |
shopping |
extractShopping |
your_instagram_activity/shopping/recently_viewed_items.json |
no |
subscription_status |
extractSubscriptionStatus |
your_instagram_activity/subscriptions/show_exclusive_story_promo_setting.json |
no |
likes_summary |
extractLikesSummary |
your_instagram_activity/likes/liked_comments.json, your_instagram_activity/likes/liked_posts.json |
no |
feed_impressions |
extractFeedImpressions |
ads_information/ads_and_topics/ads_clicked.json, ads_information/ads_and_topics/ads_viewed.json, ads_information/ads_and_topics/posts_viewed.json, ads_information/ads_and_topics/videos_watched.json |
no |
ad_impressions_detail |
extractAdImpressionsDetail |
ads_information/ads_and_topics/ads_viewed.json |
no |
clicked_ads |
extractClickedAds |
ads_information/ads_and_topics/ads_clicked.json |
no |
attention_mining |
extractAttentionMining |
ads_information/ads_and_topics/ads_viewed.json, ads_information/ads_and_topics/posts_viewed.json, ads_information/ads_and_topics/videos_watched.json |
no |
story_interactions |
extractStoryInteractions |
your_instagram_activity/story_interactions/stories_viewed.json, your_instagram_activity/story_interactions/story_likes.json |
no |
comments_posted |
extractCommentsPosted |
matched by path pattern over the file list | no |
social_graph |
extractSocialGraph |
connections/followers_and_following/blocked_profiles.json, connections/followers_and_following/following.json, connections/followers_and_following/recent_follow_requests.json, connections/followers_and_following/recently_unfollowed_profiles.json |
no |
threads |
extractThreads |
your_instagram_activity/threads/blocked_profiles.json, your_instagram_activity/threads/followers.json, your_instagram_activity/threads/following.json, your_instagram_activity/threads/personal_information.json, your_instagram_activity/threads/recent_follow_requests.json, your_instagram_activity/threads/threads_viewed.json, your_instagram_activity/threads/word_or_phrase_searches.json |
no |
muted_creators |
extractMutedCreators |
your_instagram_activity/subscriptions/your_muted_story_teaser_creators.json |
no |
see_less_topics |
extractSeeLessTopics |
preferences/your_topics/your_ads_see_more/see_less_topics.json |
no |
cpm_tier |
detectCpmTier |
ads_information/ads_and_topics/ads_viewed.json, ads_information/ads_and_topics/posts_viewed.json, ads_information/ads_and_topics/videos_watched.json |
no |
privacy_callouts |
extractPrivacyCallouts |
connections/contacts/synced_contacts.html, connections/contacts/synced_contacts.json, connections/followers_and_following/close_friends.html, connections/followers_and_following/close_friends.json |
yes |
messages_summary |
extractMessagesSummary |
matched by path pattern over the file list | yes |
extracted and cpm_tier are the site's value estimate: how many ad
impressions the export implies per year, priced at a base CPM that is
lowered for some locales. Those constants are in this file because they are
in the served file; they are the site's assumptions, not a benchmark, and the
report labels the result as an estimate.
loadZipFiles(zip) walks the archive before anything is parsed and decides
whether it is an Instagram export at all. It looks for one of two folder
names, at the root or one folder deep: your_instagram_activity/ or
apps_and_websites_off_of_instagram/. Then:
| Situation | Result |
|---|---|
| An Instagram anchor is found | Accepted. A wrapping root folder (instagram-<user>-<date>-<id>/) is stripped; backslash entry names from re-zipped exports are normalised; JSON files are parsed and mojibake-fixed; HTML files are kept as raw text. |
| Both an Instagram and a Facebook tree are present (a combined Accounts Center export) | Accepted as Instagram, whatever order the entries come in. |
| Only a Facebook tree is found | Refused, error code WRONG_PLATFORM_FACEBOOK. |
| No Instagram anchor at all | Refused, error code NOT_AN_INSTAGRAM_EXPORT. If Meta-style folders (ads_information/, personal_information/, ...) are present the message suggests a category-limited or media-only export; otherwise it says the archive does not look like an Instagram export and names the first few files it saw. |
An Instagram anchor, but not a single .json or .html file |
Refused, error code NOT_A_RECOGNIZED_EXPORT. |
The report refuses to render rather than produce a half-wrong Instagram
report from data that is not an Instagram export. tests/load-zip-files.test.js
covers every row of that table through a duck-typed fake ZIP.
Meta offers the export as JSON or as HTML. This parser was written for JSON. Of the 37 extractors, 8 can also read the HTML pages:
extractIdentity(identity)extractAudience(audience)extractAIInterests(ai_interests)extractTopics(topics)extractAdvertisers(advertisers)extractOffMeta(off_meta)extractPrivacyCallouts(privacy_callouts)extractMessagesSummary(messages_summary)
extractWindow has a partial HTML mode (marked partial in the table): with
no download-request metadata to read, it assumes Meta's default 365-day
window ending on the export date in the ZIP's name and flags the result
_inferred. The other 28 extractors return their empty
shape on an HTML export, and the report says so on screen rather than
showing zeros as facts.
That gap is deliberate, not a to-do list. The per-event files (ads viewed, posts viewed, videos watched, stories, likes, link taps) only ever had JSON readers written for them, because JSON is the format Meta selects by default and the one the report was built on. HTML pages carry the same events in a layout that changes with Meta's redesigns and with the account's language; reading them reliably would mean maintaining a second parser against a moving target for a minority of uploads. If you want a full report, request the JSON format.
Per-extractor failure isolation. extractAll wraps every extractor in
safe(). An extractor that throws, typically because Meta changed a file's
shape, does not take the report down: its error message is recorded under
_extract_errors, and the extractor is re-run against an empty archive so
that the section receives its own canonical empty shape, the same one a
missing file produces. One broken section, thirty-something intact ones.
Honest empty shapes. With no input at all, extractAll({}) returns every
key with a shape that says "nothing here" rather than a plausible number:
null for a device that was never seen, available: false for a section
with no source file, ad_annualized: false when there is no window to
annualize over. tests/extract-all-empty.test.js pins each of them.
Locale-agnostic reading. Meta localizes the export into the account's
language, including the keys of string_map_data objects and the labels of
label_values entries. The extractors try the English key first and fall
back to structure and value patterns (the entry carrying a timestamp is the
time, the http-shaped value is the link, the field labelled URL keeps its
English name in every locale), so a Russian or Spanish export reads the same
as an English one.
The mojibake fix. Meta's JSON writes non-ASCII text double-encoded: the
UTF-8 bytes of a string appear as individual \u00XX escapes, so after
JSON.parse a Cyrillic or accented word is a run of Latin-1 characters that
are really bytes in disguise. fixMetaMojibakeString reinterprets such a
string's code points as bytes and decodes them as UTF-8, strictly: a string
that is not valid UTF-8 under that reading is returned unchanged, and a
string that already contains characters outside Latin-1 is never touched.
fixMetaMojibakeDeep applies it to every string in a parsed file, keys
included, at load time, so no extractor has to think about it.
Only http(s) URLs pass through. Every URL taken from the export goes
through safeHttpUrl, which drops anything that is not http:// or
https://. A tampered archive cannot plant a javascript: link in a report.
npm testruns 41 tests under Node's built-in test runner
(node --test, Node 21 or newer, nothing to install). They need no real
export: tests/fixture/export/ is a small synthetic export (Example Corp,
example_user, every date in January 2025; see tests/fixture/README.md)
that covers the main folders and is asserted against the recorded schema so
it cannot drift from the shapes Meta ships. The suite also runs
extractAll({}) on nothing, the mojibake cases, and the archive-detection
rules above.
To run the same suite against your own export, unzip it and point the
loader at the folder that contains your_instagram_activity/:
IG_EXPORT_PATH=/path/to/your/unzipped/export npm testNothing is read from that path except by the tests, and nothing about it is recorded anywhere.
const { loadZipFiles, extractAll } = require('./report.js');
// In a browser: `zip` is a JSZip instance. Anywhere else: any object with a
// `files` map whose entries expose `dir` and `async('string')` works; the
// parser is duck-typed. `files._fileSizes` is filled only when the zip exposes
// JSZip's uncompressed size; a duck-typed zip gets an empty map.
// tests/_lib/fake-zip.js is one such object, and tests/_lib/load-export.js
// builds the same `files` dict straight from a directory.
const files = await loadZipFiles(zip, (done, total) => {}, 'instagram-....zip');
const report = extractAll(files);extractAll(files) is the whole report. The other five exports are the pieces
it is built from, exported so a caller can use one without the rest:
loadZipFiles(zip, onProgress, filename)— read a zip into thefilesdict every other function takes.parseExportDate(files)— the date Meta generated the export, read from the archive's root folder or file name;''when neither carries one.fixMetaMojibakeString(s)/fixMetaMojibakeDeep(obj)— repair Meta's double-encoded UTF-8, in one string or throughout a structure.extractSocialGraph(files)— followers, following, and who does not follow back. Returns an honest empty shape when the export's follower list is incomplete, which Meta does ship: it reports what it found rather than guessing at the difference.
loadZipFiles logs two lines to the console per successful load (the detected platform
and root folder, then the archive format). An extractor that throws also logs
one warning.
Under Node there is no DOMParser, so the HTML branches return their empty
shapes; the JSON path is complete.
tests/meta-schema-snapshot.json records the shape of a real export: its
folder tree (23 folders), its JSON files
(50), and each file's top-level keys. Never a value. It
is the canary for the failure mode that hurts most: Meta renames a file or a
key, the extractor that read it quietly returns its empty shape, and nothing
throws.
IG_EXPORT_PATH=/path/to/export npm run schema-check # diff your export's shape against the snapshot
IG_EXPORT_PATH=/path/to/export npm run schema-update # accept a new baseline
npm run schema-check # no export: check the fixture against the snapshotscripts/schema-check.js documents exactly what is compared. The messages
tree is excluded on purpose (its folder names are per conversation), and
--update is refused when there is no real export, so the synthetic fixture
can never overwrite the recorded shape.
report.js is one file of 4554 lines: an IIFE with about thirty
private helpers, the 37 extractors, the extractAll
orchestrator, and a six-function export guarded on typeof module. It is
not split because the site has no build step, and without a bundler
closure-scoped helpers cannot cross <script> files except through a global
namespace and a load-order contract, which is a worse structure than one long
file with a table of contents. It will be split when one of these becomes
true: a second platform's parser needs to share the helpers; the file passes
10,000 lines; or the codebase gains a second regular contributor. None is
true today. Keeping it as one file here also keeps the check that matters a
one-line diff.
3.0.0 — Breaking. Several fields changed meaning or shape, so read this list before upgrading; most of it is about telling "not measured" apart from "measured zero".
parseExportDatemay now return''when neither the archive's root folder nor its file name carries a date; it used to return the day the parser ran, which read as the day Meta made the export. Callers that parse the date must handle the empty string.privacy_callouts.close_friendsisnull(not measured) when there is no close-friends file, when its shape is not one the parser can count, or when any entry in it is unreadable. It used to be0, which read as a measured zero.- New:
advertisers.source_state, one ofok,list_empty,shape_unknown,unreadableorabsent, saying why an advertiser list came back empty. It is a fixed token about the archive's shape, never a value from it. - New:
attention_mining.ad_window, the posts, reels and ads counted inside the ads log's own time span (the existing fields keep counting over the union of all three logs). It isnullunless the ads log spans at least 5 days. - New:
files._zipPaths(string[]), every file in the archive, media included, andfiles._fileSizes({ path: bytes }), each file's uncompressed size._fileSizesis filled only when the zip exposes JSZip's uncompressed size; a duck-typed zip gets an empty map.loadZipFilesalso recordsfiles._zipFileCount, the number of files in the archive (media included; folders,__MACOSX/,.DS_Store,Thumbs.dbanddesktop.inileft out). - Each view log under
feed_impressions.sources(posts,videos,ads_viewed,ads_clicked) now carriesavailable: false when the archive has no such log, or carries it in a shape the parser does not recognize, so a log that was not read and a log with zero entries can be told apart. link_history: a tap whose link carriesutm_content=link_in_bioandfbclidis no longer counted as an ad unless it also carriescampaign_id,ad_idoradset_id. Organic link-in-bio taps carryfbclidtoo.- The click-interest rules now match whole words, and the ad-cluster terms no longer match inside a different word ("canva" in "canvas"), so cluster and interest membership can differ on the same input.
- A single-record file shipped as one bare object instead of a one-element
array is now read for
link_history.json,close_friends.jsonandrecently_unfollowed_profiles.json. - Comments no longer carry dates of internal runs, figures measured on a particular person's archive, or the names of the private systems that read the parser's output. The file now opens with an SPDX license line.
2.1.0 — the interest vocabularies are rebuilt from household-name brands and
plain category words. clusterAdImpressions and the click-interest rules used
to carry token lists grown by reading one real export, so their membership
described that person's advertisers rather than anything about parsing. A
city-named bucket is deleted outright. Measured on a real export, the cluster
step now categorizes fewer of that particular archive's advertisers and should
generalize better to any other. Comments no longer quote statistics measured
from real archives, or the nationality and export date of the people who
contributed them.
2.0.1 — removals only, no API change. A 53-entry advertiser-name → domain
table and the appDomain() helper it fed are gone. The helper had no caller, so
nothing changes for a consumer; the table went because its membership described
one person's advertisers rather than anything about parsing. Domains still come
from the export's own link URLs. Comments no longer cite internal repository
paths.
2.0.0 — the first refresh since the 2026-09-02 publish. Breaking, because the report object lost fields:
estimateExtractedAnnual(the money object) no longer emitsad_revenue, the per-window counts, the monthly figures oradvertiser_count. Nothing is lost: each is derivable from fields the report still carries, andad_revenuewas a second name fortotal. Corrected in 3.0.0: this line used to say a function calledcomputeMoneystopped emittingannual_impressions. No function has that name, andannual_impressionsis still emitted.- New export:
extractSocialGraph(files)— followers, following, and who does not follow back. It returns an honest empty shape when Meta's export ships an incomplete follower list, which it does; it reports what it found rather than guessing at the difference. scripts/schema-check.jsno longer defines its own shape rule. The rule now ships asscripts/_meta-shape.js, copied from the same source the parser's own checks use, so the two cannot drift apart.
If you cloned 1.0.0 and read either removed field, derive it as described above before upgrading.
AGPL-3.0-only. Copyright OPT2IN LLC. The full text is in LICENSE. The parser also ships inside Opt2In's iOS app under separate terms, which is why external changes are not merged here (see CONTRIBUTING.md); the AGPL binds licensees, not the licensor, so the two coexist as long as every line stays OPT2IN LLC's own.