<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Follower Change</title>
    <description>The latest articles on DEV Community by Follower Change (@followerchange).</description>
    <link>https://dev.to/followerchange</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4200285%2Faa56de78-bc2d-4e5e-889a-0ce35c239ac9.png</url>
      <title>DEV Community: Follower Change</title>
      <link>https://dev.to/followerchange</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/followerchange"/>
    <language>en</language>
    <item>
      <title>Comparing Snapshots Without Lying to Yourself: The Set Logic of Follower Changes</title>
      <dc:creator>Follower Change</dc:creator>
      <pubDate>Fri, 23 Oct 2026 17:22:46 +0000</pubDate>
      <link>https://dev.to/followerchange/comparing-snapshots-without-lying-to-yourself-the-set-logic-of-follower-changes-2o2d</link>
      <guid>https://dev.to/followerchange/comparing-snapshots-without-lying-to-yourself-the-set-logic-of-follower-changes-2o2d</guid>
      <description>&lt;p&gt;Every follower tracker answers its questions with set operations, whether it admits it or not. When we built the historical comparison in &lt;a href="https://followerchange.com/follower-history?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=dev-snapshot-diffing" rel="noopener noreferrer"&gt;Follower Change&lt;/a&gt;, the engineering turned out to be the easy part — a few set differences over normalized username lists. The hard part was epistemology: making sure the words in the UI never claimed more than the sets could prove. This post is the data model, and the honesty rules that fall out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two lists, three questions
&lt;/h2&gt;

&lt;p&gt;A platform data export gives you, for one moment in time, two sets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;F&lt;/strong&gt; — your followers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;G&lt;/strong&gt; — accounts you follow ("following")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From a single export, exactly one comparison is sound:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;G \ F&lt;/strong&gt; — accounts you follow that don't follow you back. This is a statement about &lt;em&gt;now&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing in a single snapshot describes change. Change requires two exports of the same list at different times — say &lt;strong&gt;F₁&lt;/strong&gt; (earlier) and &lt;strong&gt;F₂&lt;/strong&gt; (later):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;F₂ \ F₁&lt;/strong&gt; — accounts present later that weren't there before: new followers (or returners).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;F₁ \ F₂&lt;/strong&gt; — accounts present before that are absent now.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That second set is where the lying starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Absence is not an action
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;F₁ \ F₂&lt;/strong&gt; is routinely labeled "unfollowers." But set membership can't tell you &lt;em&gt;why&lt;/em&gt; an element disappeared, and the possible causes are not interchangeable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the person unfollowed you;&lt;/li&gt;
&lt;li&gt;the account was deleted — by its owner, or by the platform;&lt;/li&gt;
&lt;li&gt;the account was suspended;&lt;/li&gt;
&lt;li&gt;the account was renamed, and the same person is sitting in &lt;strong&gt;F₂&lt;/strong&gt; under a new handle, possibly also appearing in &lt;strong&gt;F₂ \ F₁&lt;/strong&gt; as a phantom "new follower";&lt;/li&gt;
&lt;li&gt;the export itself was partial — a split or truncated file can manufacture absences wholesale.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the honest label for &lt;strong&gt;F₁ \ F₂&lt;/strong&gt; is "&lt;strong&gt;may have unfollowed&lt;/strong&gt;" — or, in product language, "lost followers." Anything stronger ("X unfollowed you on Tuesday") is a claim the data cannot support, because exports are snapshots: they carry no event log and no timestamps for individual joins or leaves. Any tool that gives you an exact unfollow &lt;em&gt;date&lt;/em&gt; from export data is interpolating — or inventing.&lt;/p&gt;

&lt;p&gt;The rename case deserves emphasis because it corrupts both difference sets simultaneously: one departure and one arrival, both phantoms, for a single human who changed nothing about following you. Username-keyed diffing cannot detect it without stable account IDs, and most exports don't provide them consistently. A well-behaved tool says this out loud in its limits section instead of letting the numbers pose as certainty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalization decides your error rate
&lt;/h2&gt;

&lt;p&gt;Before any diffing, both snapshots pass through the same normalization, because exports are inconsistent in ways that masquerade as change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Case&lt;/strong&gt;: handles are case-insensitive on most platforms but exported inconsistently; lowercase everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;@&lt;/code&gt; prefix&lt;/strong&gt;: present in some formats, absent in others, occasionally present &lt;em&gt;within&lt;/em&gt; a file. Strip it as a leading character only — an &lt;code&gt;@&lt;/code&gt; anywhere else is data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whitespace and empties&lt;/strong&gt;: blank entries (trailing commas, empty JSON strings) are excluded, or your "new followers" list fills with ghosts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split files&lt;/strong&gt;: some platforms shard large lists (&lt;code&gt;followers_1.json&lt;/code&gt;, &lt;code&gt;followers_2.json&lt;/code&gt;, …). Compare against the &lt;em&gt;union&lt;/em&gt; of the shards — diffing against one shard manufactures thousands of fake departures.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get normalization wrong and the set logic is flawless arithmetic over garbage.&lt;/p&gt;

&lt;h2&gt;
  
  
  A worked miniature
&lt;/h2&gt;

&lt;p&gt;Earlier export &lt;strong&gt;F₁&lt;/strong&gt; = {ana, bilal, chen, dana}. Later export &lt;strong&gt;F₂&lt;/strong&gt; = {ana, chen, dana, eli}.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;F₂ \ F₁&lt;/strong&gt; = {eli} — one arrival.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;F₁ \ F₂&lt;/strong&gt; = {bilal} — one absence. Bilal may have unfollowed you. Bilal may also have deleted the account, been suspended, or renamed to "eli" — in which case both sets lied in opposite directions and the truth is &lt;em&gt;zero&lt;/em&gt; change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four-element sets make this look trivial; at 40,000 followers the same ambiguity applies to every element, which is why reporting both numbers &lt;em&gt;with their caveat attached&lt;/em&gt; is the entire game.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we let the UI say
&lt;/h2&gt;

&lt;p&gt;The rules we ended up with, stated as UI copy constraints:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Current-state language for single snapshots ("doesn't follow you back"), change language only for snapshot pairs.&lt;/li&gt;
&lt;li&gt;Historical departures are "may have unfollowed," always — no exceptions for "obvious" cases, because the obvious cases are where the rename trap lives.&lt;/li&gt;
&lt;li&gt;Exact dates are never attached to a departure. The export dates bound the window; that's all that can be claimed.&lt;/li&gt;
&lt;li&gt;The limits ship next to the results, not in a FAQ nobody opens.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this requires exotic engineering — a &lt;code&gt;Set&lt;/code&gt;, a normalize function, and the discipline to label outputs by what was actually computed. But it does require deciding, before you write the results screen, whether you're building a measurement tool or a story generator. Follower data is emotional; people read a departure list as a list of rejections. The least a tool owes them is not dressing set absence up as human intent.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follower Change applies this model to the official exports of Instagram, TikTok, X, Threads, YouTube, Facebook, and LinkedIn — comparing snapshots locally in the user's browser, with the files never leaving the device.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>data</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Trust the Bytes, Not the Filename: Content Detection and Safe Parser Routing in the Browser</title>
      <dc:creator>Follower Change</dc:creator>
      <pubDate>Fri, 23 Oct 2026 14:15:40 +0000</pubDate>
      <link>https://dev.to/followerchange/trust-the-bytes-not-the-filename-content-detection-and-safe-parser-routing-in-the-browser-1nna</link>
      <guid>https://dev.to/followerchange/trust-the-bytes-not-the-filename-content-detection-and-safe-parser-routing-in-the-browser-1nna</guid>
      <description>&lt;p&gt;When a user uploads a data export to &lt;a href="https://followerchange.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=dev-content-detection" rel="noopener noreferrer"&gt;Follower Change&lt;/a&gt;, the file arrives with three claims about what it is: its name, its extension, and the MIME type the browser guessed. Our parser believes none of them. Everything it routes — a ZIP archive, JSON, an HTML export, a JavaScript data file, a CSV — is identified by its content, in a fixed order, before any parser is allowed to touch it. This post walks through that detection pipeline and the safety rules around it, because "what is this file?" is a security question, not a convenience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0: Read the head once, normalize it
&lt;/h2&gt;

&lt;p&gt;Detection works on two views of the same bytes: the raw prefix for binary signatures, and a decoded text head for everything else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Mirrors production (lib/parser/detect.ts), lightly condensed.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TEXT_HEAD_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2048&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;textHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;head&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TEXT_HEAD_BYTES&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fatal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The head is 2,048 bytes, decoded as UTF-8 with replacement (never throwing on invalid sequences), then trimmed and lowercased before matching. Lowercasing once, up front, means every rule after it can compare against lowercase patterns — export files do not respect your casing expectations, and &lt;code&gt;Window.YTD&lt;/code&gt; is not a different format from &lt;code&gt;window.ytd&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Containers by magic bytes — the full signature
&lt;/h2&gt;

&lt;p&gt;A ZIP archive announces itself in its first four bytes. We compare all four, against all three signatures a ZIP can open with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Production signatures, verbatim.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ZIP_MAGIC&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mh"&gt;0x50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x4b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x03&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x04&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// standard zip — "PK\x03\x04"&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mh"&gt;0x50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x4b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x06&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// empty archive&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mh"&gt;0x50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x4b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x07&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x08&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// spanned archive&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking a partial prefix is how a file that merely starts with the letters "PK" gets routed to an archive parser. Four bytes is the signature; anything less is a guess. If the magic matches, the file is an archive no matter what its name claims — and if it doesn't, no &lt;code&gt;.zip&lt;/code&gt; extension can promote it later (the extension fallback below only applies when nothing in the content matched at all).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Text formats by leading structure, in a fixed order
&lt;/h2&gt;

&lt;p&gt;JSON, HTML, JavaScript data files, and CSV have no magic bytes, so the lowercased text head is matched structurally — in this exact order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Order mirrors production: JSON → HTML → X-JS → CSV → extension.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;head&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;textHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trimStart&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;!doctype html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;window.ytd.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xjs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// X archive data files&lt;/span&gt;

&lt;span class="c1"&gt;// CSV: structural, explained below.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;looksLikeSubscriptionsCsv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;csv&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Nothing matched: the extension gets the last word, and only then.&lt;/span&gt;
&lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;extensionOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;htm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zip&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zip&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xjs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;csv&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;csv&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(The CSV check and the helper names are simplified for readability; the order and the matching rules are the production ones. MIME types are never consulted anywhere in the pipeline.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Order is not pedantry. A JSON payload can contain the string &lt;code&gt;&amp;lt;html&lt;/code&gt; in a bio field; an X data file is JavaScript that is &lt;em&gt;not&lt;/em&gt; JSON until its prefix is removed. The first matching rule wins, so the rules are sequenced from the most structurally certain to the least.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: CSV — the format with no signature
&lt;/h2&gt;

&lt;p&gt;CSV is the honest problem child: no magic bytes, no doctype, no reliable opening token — and in our pipeline it carries two real exports: &lt;strong&gt;YouTube Takeout's &lt;code&gt;subscriptions.csv&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;LinkedIn's &lt;code&gt;Connections.csv&lt;/code&gt;&lt;/strong&gt; (the latter identified by its path when it arrives inside LinkedIn's archive; the shared CSV reader handles both).&lt;/p&gt;

&lt;p&gt;YouTube's header names vary by locale, so detection can't match a literal header string. What production matches is the &lt;em&gt;shape&lt;/em&gt; of the first line: it must contain commas, and its first field must match &lt;code&gt;/channel/i&lt;/code&gt; after trimming an optional opening quote — the one structural constant across localizations of "Channel Id, Channel Url, …".&lt;/p&gt;

&lt;p&gt;Once routed, the CSV reader itself (a minimal RFC-4180-style parser: quoted fields, &lt;code&gt;""&lt;/code&gt; escapes, embedded commas and newlines) treats the file as hostile until proven otherwise: caps on total bytes (20 MB), rows (200,000), and field length (4,096 characters), with a hard failure — not a truncation — when a quoted field never terminates. A CSV from an export service is still an uploaded file, and uploaded files get limits, not trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Routing is a security boundary
&lt;/h2&gt;

&lt;p&gt;Detection's output is a &lt;em&gt;route&lt;/em&gt;, not a verdict. Each route leads to exactly one parser, and each parser only ever sees bytes that claimed its shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;JSON&lt;/strong&gt; route validates defensively — fields are typed as &lt;code&gt;unknown&lt;/code&gt; and narrowed with guards; undocumented fields are ignored, documented ones type-checked.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;X-JS&lt;/strong&gt; route never executes anything: the &lt;code&gt;window.YTD.… =&lt;/code&gt; assignment prefix is stripped &lt;em&gt;as text&lt;/em&gt; and the remainder is handed to &lt;code&gt;JSON.parse&lt;/code&gt;. (This was the centerpiece of our earlier article, so here it gets one sentence — the point for detection is that a distinct route exists precisely so this file never reaches a JSON parser raw or an evaluator at all.)&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;HTML&lt;/strong&gt; route parses with a static parser and harvests profile links from anchor &lt;code&gt;href&lt;/code&gt;s. The markup is never inserted into the DOM. An export file is untrusted input with a download button's good reputation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 5: An archive is a bundle of untrusted files
&lt;/h2&gt;

&lt;p&gt;A detected ZIP changes the question from "what is this file?" to "what is &lt;em&gt;each&lt;/em&gt; file inside it?" Our archive layer (fflate, fully client-side) therefore re-runs detection &lt;strong&gt;per entry&lt;/strong&gt; — the container's identity says nothing about its contents — and enforces, around that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Traversal is impossible by construction:&lt;/strong&gt; entries are never written to disk, and entries whose names contain &lt;code&gt;..&lt;/code&gt; segments, absolute paths, or drive letters are skipped outright.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sizes are checked twice:&lt;/strong&gt; each entry's declared size and the archive's declared total are capped &lt;em&gt;before&lt;/em&gt; extraction, and the actual decompressed sizes are verified again after — a central directory can lie, so the defense doesn't rely on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The entry count is capped&lt;/strong&gt; (200 in our configuration), turning pathological archives into a clear user-facing error instead of a frozen tab.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 6: Validate after routing, fail without leaking
&lt;/h2&gt;

&lt;p&gt;Only after routing does validation begin, and its failures are designed to say nothing useful to an attacker: parse errors carry stable codes and user-safe messages, and never echo file contents back into the UI. "We know what this claims to be" and "this is well-formed" stay separate claims, checked by separate layers, in that order.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline, on one screen
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Magic bytes first — full signatures, containers only.&lt;/li&gt;
&lt;li&gt;A 2,048-byte, lowercased text head for everything else.&lt;/li&gt;
&lt;li&gt;Structural matches in certainty order: JSON → HTML → X-JS → CSV.&lt;/li&gt;
&lt;li&gt;Extension as fallback only; MIME never.&lt;/li&gt;
&lt;li&gt;CSV detected by shape, parsed with hard caps.&lt;/li&gt;
&lt;li&gt;One route per format; parsers validate defensively; HTML stays inert; JS data is text, never code.&lt;/li&gt;
&lt;li&gt;Archives re-detect every entry, skip hostile names, and verify sizes twice.&lt;/li&gt;
&lt;li&gt;Errors are stable, generic, and content-free.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Seven platforms ship our analyzer seven mutually inconsistent export formats. The detection pipeline is why the rest of the system never has to care which one a user is holding — and why a misnamed, mislabeled, or hostile file meets the same fixed sequence of questions before it meets any parser at all.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Seven platforms, seven export formats, one rule: the files never leave the browser</title>
      <dc:creator>Follower Change</dc:creator>
      <pubDate>Thu, 22 Oct 2026 11:44:47 +0000</pubDate>
      <link>https://dev.to/followerchange/seven-platforms-seven-export-formats-one-rule-the-files-never-leave-the-browser-2646</link>
      <guid>https://dev.to/followerchange/seven-platforms-seven-export-formats-one-rule-the-files-never-leave-the-browser-2646</guid>
      <description>&lt;p&gt;When we built &lt;a href="https://followerchange.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=dev-client-side-parsing" rel="noopener noreferrer"&gt;Follower Change&lt;/a&gt;, the first architectural decision was also the constraint everything else had to survive: &lt;strong&gt;the analysis runs entirely in the user's browser.&lt;/strong&gt; A follower export is someone's whole social graph. We didn't want it on our servers, so the parser, the comparison engine, and the results all live client-side. No upload endpoint exists for follower data — there's nothing to call.&lt;/p&gt;

&lt;p&gt;That decision sounds simple until you meet the actual export formats. "Download your data" turns out to mean something different on every platform:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instagram&lt;/strong&gt; gives you JSON, but the shape depends on which list you asked for. Followers arrive as a top-level array of entries, each carrying &lt;code&gt;string_list_data&lt;/code&gt; with a &lt;code&gt;value&lt;/code&gt; (the username) and a timestamp. Following comes wrapped in a &lt;code&gt;relationships_following&lt;/code&gt; object instead. And on larger accounts the followers list is split across shard files — &lt;code&gt;followers_1.json&lt;/code&gt;, &lt;code&gt;followers_2.json&lt;/code&gt;, and so on — which a user may upload one at a time. (An early lesson: shards from different platforms behave differently; our parser merges Facebook's shards and X's parts automatically, and we had to be explicit with users that Instagram's split files each need uploading.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;X&lt;/strong&gt; doesn't give you JSON at all. Its archive contains JavaScript files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;YTD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;follower&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;part0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;follower&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;accountId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1252851&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Executing that in the browser would be an obvious non-starter — uploaded bytes are untrusted input. We strip the &lt;code&gt;window.YTD.&amp;lt;key&amp;gt;.part&amp;lt;N&amp;gt; =&lt;/code&gt; prefix as &lt;em&gt;text&lt;/em&gt; and &lt;code&gt;JSON.parse&lt;/code&gt; the remainder. Same rule everywhere in the pipeline: no &lt;code&gt;eval&lt;/code&gt;, no DOM injection, no executing anything a user uploads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HTML exports&lt;/strong&gt; (Instagram offers one) get parsed inertly: we harvest &lt;code&gt;instagram.com&lt;/code&gt; profile links and nothing else — no scripts run, no markup is rendered.&lt;/p&gt;

&lt;p&gt;Underneath the formats, the pipeline is deliberately boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;upload → validation → format detection → parser → normalize → dedupe → compare
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details from that pipeline are worth stealing for any file-processing tool:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never trust the MIME type or the extension.&lt;/strong&gt; Format detection goes magic bytes → content sniffing → extension, in that order. Users rename files; platforms mislabel them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalize usernames aggressively, but honestly.&lt;/strong&gt; &lt;code&gt;@Alice&lt;/code&gt;, &lt;code&gt;Alice&lt;/code&gt;, and a full profile URL all canonicalize to &lt;code&gt;alice&lt;/code&gt; before deduplication, or your comparison fills with phantom differences. But normalization has rules you can't invent: when we documented the spreadsheet version of this comparison, the correct formula removes &lt;code&gt;@&lt;/code&gt; &lt;em&gt;only when it's the first character&lt;/em&gt; — blanket replaces corrupt usernames that legitimately contain odd characters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap everything.&lt;/strong&gt; ZIPs (we unzip in-browser with fflate) get a hard entry cap — ours is 200 entries — plus traversal guards, because an archive is just a polite name for a bundle of untrusted files. Oversized full-account archives are the common failure, so the error message tells users to upload the follower files directly instead.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What does the comparison itself look like once the data is clean? Two set operations, honestly labeled. Followers minus following (and vice versa) on a &lt;em&gt;current&lt;/em&gt; export tells you who doesn't follow you back. Comparing two &lt;em&gt;snapshots&lt;/em&gt; from different dates tells you which accounts were present before and absent later — and we label that "may have unfollowed" / "lost followers," never "unfollowed you," because absence from a later export doesn't prove intent, timing, or even that the account still exists. An analyzer that claims more than the data supports is lying to its users in a friendly font.&lt;/p&gt;

&lt;p&gt;The browser-only constraint costs us things a server would make easy: no background processing for huge archives, no cross-device history unless a user saves a report to their account. We think it's the right trade. The data was never ours to hold.&lt;/p&gt;

&lt;p&gt;If you want to try it: Follower Change works from the official exports of Instagram, Threads, TikTok, X, Facebook, YouTube, and LinkedIn. A free account includes 100 credits and an analysis costs 10 — the account exists for credits and saved reports; your follower files still never leave your browser.&lt;/p&gt;

&lt;p&gt;I'm happy to answer questions about any of the export formats — especially if you've fought the X archive or TikTok's JSON before. Those two have the most surprises per kilobyte.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>privacy</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
