<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged threads</title>
<link>https://coderlegion.com/tag/threads</link>
<description>Powered by Question2Answer</description>
<item>
<title>How to Scrape Threads with Node.js: Profiles, Posts, Replies, and an Engagement Tracker (2026)</title>
<link>https://coderlegion.com/31945/how-to-scrape-threads-with-node-js-profiles-posts-replies-and-an-engagement-tracker-2026</link>
<description>&lt;p&gt;Threads is an awkward target. The web app is a React client from Meta, most of the interesting data loads through internal GraphQL calls, and a logged-out visitor sees only a slice of what a logged-in one does. Still, a lot of public data ships inside the first HTML response, and for a modest job like &quot;track likes and replies on one account's recent posts&quot; that slice is enough.&lt;/p&gt;
&lt;p&gt;This tutorial builds a small threads scraper in Node.js 18+ with nothing but native &lt;code&gt;fetch&lt;/code&gt;. It reads a public profile, pulls its recent posts with like, reply, repost, and quote counts, and appends a timestamped snapshot to a JSON Lines file every time it runs. At the end there's a short report script that shows which posts gained the most engagement between runs.&lt;/p&gt;
&lt;p&gt;I'll also be upfront about where this breaks, because on Threads it breaks often.&lt;/p&gt;
&lt;h3 id=&quot;what-a-logged-out-visitor-can-reach&quot;&gt;What a logged-out visitor can reach&lt;/h3&gt;
&lt;p&gt;Short version, as of writing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Profile pages&lt;/strong&gt; (&lt;code&gt;https://www.threads.com/@handle&lt;/code&gt;): name, bio, follower count, verification, and the first batch of recent posts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Single post pages&lt;/strong&gt; (&lt;code&gt;https://www.threads.com/@handle/post/&amp;lt;code&amp;gt;&lt;/code&gt;): the post itself and usually a first batch of replies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Search, feeds, follower lists, &quot;load more&quot;&lt;/strong&gt;: these either show a login prompt or depend on GraphQL calls that need session tokens. Treat them as off-limits for a no-login scraper.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Threads moved its main domain from &lt;code&gt;threads.net&lt;/code&gt; to &lt;code&gt;threads.com&lt;/code&gt; in 2025. Old &lt;code&gt;threads.net&lt;/code&gt; links redirect, so let &lt;code&gt;fetch&lt;/code&gt; follow redirects and normalize URLs to &lt;code&gt;threads.com&lt;/code&gt; in your own storage so the same post doesn't get two keys.&lt;/p&gt;
&lt;h3 id=&quot;where-the-data-actually-lives&quot;&gt;Where the data actually lives&lt;/h3&gt;
&lt;p&gt;If you open the page source of a profile, you won't find the posts as readable HTML. What you will find are many &lt;code&gt;&amp;lt;script type=&quot;application/json&quot;&amp;gt;&lt;/code&gt; tags, several marked with a &lt;code&gt;data-sjs&lt;/code&gt; attribute. These are server-side preloaded data for Meta's Relay client, and some of them contain the same post objects the app renders: &lt;code&gt;code&lt;/code&gt;, &lt;code&gt;taken_at&lt;/code&gt;, &lt;code&gt;like_count&lt;/code&gt;, &lt;code&gt;caption.text&lt;/code&gt;, and a &lt;code&gt;text_post_app_info&lt;/code&gt; object with &lt;code&gt;direct_reply_count&lt;/code&gt;, &lt;code&gt;repost_count&lt;/code&gt;, and &lt;code&gt;quote_count&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The structure is deeply nested and changes without notice, so don't hard-code a path like &lt;code&gt;result.data.mediaData.edges[0]...&lt;/code&gt;. A sturdier approach is to parse every JSON script and walk the whole tree looking for anything that &lt;em&gt;looks&lt;/em&gt; like a post. That costs a few milliseconds and survives most reshuffles.&lt;/p&gt;
&lt;h3 id=&quot;setup&quot;&gt;Setup&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir threads-scraper &amp;amp;&amp;amp; cd threads-scraper
npm init -y
npm pkg set type=module
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No dependencies. Node 18 or newer gives you global &lt;code&gt;fetch&lt;/code&gt;, and everything else comes from the standard library.&lt;/p&gt;
&lt;h3 id=&quot;step-1-fetch-a-page-politely&quot;&gt;Step 1: Fetch a page politely&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// fetchPage.js
const UA =
  &quot;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 &quot; +
  &quot;(KHTML, like Gecko) Chrome/129.0 Safari/537.36&quot;;

const sleep = (ms) =&amp;gt; new Promise((r) =&amp;gt; setTimeout(r, ms));

export async function fetchPage(url, { retries = 3 } = {}) {
  for (let attempt = 0; attempt &amp;lt;= retries; attempt++) {
    const res = await fetch(url, {
      headers: {
        &quot;user-agent&quot;: UA,
        &quot;accept&quot;: &quot;text/html,application/xhtml+xml&quot;,
        &quot;accept-language&quot;: &quot;en-US,en;q=0.9&quot;,
      },
      redirect: &quot;follow&quot;,
    });

    if (res.ok) {
      const html = await res.text();
      // A login redirect sometimes comes back as 200 with a tiny shell page.
      if (res.url.includes(&quot;/login&quot;) || html.length &amp;lt; 20_000) {
        throw new Error(`Looks like a login wall for ${url} (final URL ${res.url})`);
      }
      return html;
    }

    if (res.status === 429 || res.status &amp;gt;= 500) {
      const wait = 2 ** attempt * 5_000 + Math.random() * 2_000;
      console.warn(`HTTP ${res.status} on ${url}, retrying in ${Math.round(wait / 1000)}s`);
      await sleep(wait);
      continue;
    }
    throw new Error(`HTTP ${res.status} for ${url}`);
  }
  throw new Error(`Gave up on ${url} after ${retries + 1} attempts`);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The 20,000-character threshold is a heuristic, not a documented rule. A real profile page is far larger than that, while the shell you get when Meta wants you to sign in is much smaller. Tune it if you see false positives.&lt;/p&gt;
&lt;h3 id=&quot;step-2-pull-the-embedded-json-out-of-the-html&quot;&gt;Step 2: Pull the embedded JSON out of the HTML&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// extract.js
const SCRIPT_RE = /&amp;lt;script type=&quot;application\/json&quot;[^&amp;gt;]*&amp;gt;([\s\S]*?)&amp;lt;\/script&amp;gt;/g;

export function jsonBlobs(html) {
  const blobs = [];
  for (const m of html.matchAll(SCRIPT_RE)) {
    try {
      blobs.push(JSON.parse(m[1]));
    } catch {
      // Some blobs are not valid standalone JSON; skip them.
    }
  }
  return blobs;
}

// Walk any JSON tree and collect objects that look like Threads posts.
export function findPosts(root) {
  const found = new Map();
  const stack = [root];
  while (stack.length) {
    const node = stack.pop();
    if (!node || typeof node !== &quot;object&quot;) continue;

    if (
      typeof node.code === &quot;string&quot; &amp;amp;&amp;amp;
      typeof node.taken_at === &quot;number&quot; &amp;amp;&amp;amp;
      &quot;like_count&quot; in node &amp;amp;&amp;amp;
      node.user?.username
    ) {
      found.set(node.code, node);
    }
    for (const v of Array.isArray(node) ? node : Object.values(node)) {
      if (v &amp;amp;&amp;amp; typeof v === &quot;object&quot;) stack.push(v);
    }
  }
  return [...found.values()];
}

// Same idea for the profile header.
export function findProfile(root, handle) {
  const stack = [root];
  while (stack.length) {
    const node = stack.pop();
    if (!node || typeof node !== &quot;object&quot;) continue;
    if (node.username === handle &amp;amp;&amp;amp; &quot;follower_count&quot; in node) return node;
    for (const v of Array.isArray(node) ? node : Object.values(node)) {
      if (v &amp;amp;&amp;amp; typeof v === &quot;object&quot;) stack.push(v);
    }
  }
  return null;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;Map&lt;/code&gt; keyed by &lt;code&gt;code&lt;/code&gt; matters: the same post often appears in more than one blob, and you don't want to count it twice. Using an explicit stack instead of recursion avoids blowing the call stack on Meta's very deep payloads.&lt;/p&gt;
&lt;h3 id=&quot;step-3-normalize-a-post&quot;&gt;Step 3: Normalize a post&lt;/h3&gt;
&lt;p&gt;Raw post objects carry dozens of fields you don't need. Flatten them into something you'd actually store:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// normalize.js
export function normalizePost(p) {
  const info = p.text_post_app_info ?? {};
  return {
    code: p.code,
    url: `https://www.threads.com/@${p.user.username}/post/${p.code}`,
    author: p.user.username,
    postedAt: new Date(p.taken_at * 1000).toISOString(),
    text: p.caption?.text ?? &quot;&quot;,
    likes: p.like_count ?? null,
    replies: info.direct_reply_count ?? null,
    reposts: info.repost_count ?? null,
    quotes: info.quote_count ?? null,
    isReply: Boolean(info.is_reply),
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two caveats. First, authors can hide like counts, so &lt;code&gt;like_count&lt;/code&gt; may be missing or zero even on a popular post; store &lt;code&gt;null&lt;/code&gt; rather than guessing. Second, the profile page mixes in the author's own continuation posts (a multi-part thread shows up as a post plus replies to itself). &lt;code&gt;isReply&lt;/code&gt; lets you filter those out if you only want top-level posts.&lt;/p&gt;
&lt;h3 id=&quot;step-4-the-threads-scraper-core-a-profile-&quot;&gt;Step 4: The threads scraper core (a profile)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// profile.js
import { fetchPage } from &quot;./fetchPage.js&quot;;
import { jsonBlobs, findPosts, findProfile } from &quot;./extract.js&quot;;
import { normalizePost } from &quot;./normalize.js&quot;;

export async function scrapeProfile(handle) {
  handle = handle.replace(/^@/, &quot;&quot;);
  const html = await fetchPage(`https://www.threads.com/@${handle}`);
  const blobs = jsonBlobs(html);

  let profile = null;
  const posts = new Map();
  for (const blob of blobs) {
    profile ??= findProfile(blob, handle);
    for (const p of findPosts(blob)) {
      if (p.user.username === handle) posts.set(p.code, normalizePost(p));
    }
  }

  return {
    handle,
    followers: profile?.follower_count ?? null,
    verified: profile?.is_verified ?? null,
    bio: profile?.biography ?? null,
    posts: [...posts.values()].sort((a, b) =&amp;gt; b.postedAt.localeCompare(a.postedAt)),
  };
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const data = await scrapeProfile(process.argv[2] ?? &quot;zuck&quot;);
  console.log(data.followers, data.posts.length);
  console.table(data.posts.slice(0, 5).map(({ code, likes, replies, reposts }) =&amp;gt; ({ code, likes, replies, reposts })));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run it with &lt;code&gt;node profile.js someuser&lt;/code&gt;. Expect a first page of posts, roughly what you'd see before scrolling. If you get zero posts but a valid profile, Meta has probably changed where the posts are preloaded, or this request was served a stripped page. Save the HTML to disk and search it for a post code you know exists; that tells you quickly which case you're in.&lt;/p&gt;
&lt;h3 id=&quot;step-5-replies-on-a-single-post&quot;&gt;Step 5: Replies on a single post&lt;/h3&gt;
&lt;p&gt;The post permalink page works the same way. The main post plus the first batch of replies are usually in the preloaded JSON:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// post.js
import { fetchPage } from &quot;./fetchPage.js&quot;;
import { jsonBlobs, findPosts } from &quot;./extract.js&quot;;
import { normalizePost } from &quot;./normalize.js&quot;;

export async function scrapePost(url) {
  const clean = url.replace(&quot;threads.net&quot;, &quot;threads.com&quot;).split(/[?#]/)[0];
  const code = clean.match(/\/post\/([^/]+)/)?.[1];
  if (!code) throw new Error(`Not a Threads post URL: ${url}`);

  const all = new Map();
  for (const blob of jsonBlobs(await fetchPage(clean))) {
    for (const p of findPosts(blob)) all.set(p.code, normalizePost(p));
  }
  const post = all.get(code) ?? null;
  all.delete(code);
  return { post, replies: [...all.values()] };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&quot;Replies&quot; here means everything else on the page that looks like a post. On some pages that includes the parent post (if you opened a reply) or related posts. If you need strict parent/child relationships, inspect the surrounding &lt;code&gt;thread_items&lt;/code&gt; structure in the blob rather than relying on the flat list.&lt;/p&gt;
&lt;h3 id=&quot;why-not-just-call-the-graphql-api-&quot;&gt;Why not just call the GraphQL API?&lt;/h3&gt;
&lt;p&gt;You'll see older tutorials that POST to &lt;code&gt;https://www.threads.com/api/graphql&lt;/code&gt; with a &lt;code&gt;doc_id&lt;/code&gt; and an &lt;code&gt;lsd&lt;/code&gt; token copied from the page. It can work for a while, but plan for three problems:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;doc_id&lt;/code&gt; churn.&lt;/strong&gt; Each query is identified by a numeric document ID that changes when Meta ships a new client build. Hard-coded IDs go stale, sometimes within weeks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Token coupling.&lt;/strong&gt; The &lt;code&gt;lsd&lt;/code&gt; token and related headers are tied to the page you loaded. You end up scraping the HTML anyway just to call GraphQL.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pagination is where login walls live.&lt;/strong&gt; &quot;Load more posts&quot; and deep reply threads are exactly the calls that tend to need a session. Using a logged-in account for scraping risks that account, and it's clearly against Meta's terms.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For a no-login threads scraper, the embedded JSON is the more stable source, even though you only get the first page.&lt;/p&gt;
&lt;h3 id=&quot;rate-limits-ip-blocks-and-legal-notes&quot;&gt;Rate limits, IP blocks, and legal notes&lt;/h3&gt;
&lt;p&gt;Meta doesn't publish limits for the public web pages. From what people commonly report, a residential IP fetching a few pages a minute is fine, while datacenter IPs get challenged or redirected to login much sooner. I'd stick to these rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One request every few seconds,&lt;/strong&gt; with random jitter, and back off on 429.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cache HTML&lt;/strong&gt; during development so you're not hitting Threads every time you tweak a parser.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Watch for soft blocks.&lt;/strong&gt; A 200 with a login shell is the usual symptom, not a clean 403.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On the legal side: Meta's terms prohibit automated collection without permission, and courts in different countries have treated public-data scraping differently. Only collect public data, don't collect more personal data than your use case needs, respect GDPR if you store data about EU users, and talk to a lawyer before building anything commercial. This post is a tutorial, not legal advice.&lt;/p&gt;
&lt;h3 id=&quot;the-official-threads-api&quot;&gt;The official Threads API&lt;/h3&gt;
&lt;p&gt;Meta has an official Threads API (&lt;code&gt;graph.threads.net&lt;/code&gt;). It's the right tool when you're working with &lt;strong&gt;your own&lt;/strong&gt; account: publishing, reading your posts and their insights, managing replies. Meta has since added keyword search and some public profile lookups, but they require app review, specific permissions, and an access token tied to a Threads user, and they come with their own rate limits. Check the current docs before relying on them, because the scope has been changing.&lt;/p&gt;
&lt;p&gt;What it won't give you is an easy &quot;give me any public account's posts&quot; call with no app review. That gap is why people write scrapers in the first place.&lt;/p&gt;
&lt;h3 id=&quot;project-a-threads-engagement-tracker&quot;&gt;Project: a Threads engagement tracker&lt;/h3&gt;
&lt;p&gt;Now the practical bit. This script snapshots a few accounts and appends one line per post per run to &lt;code&gt;snapshots.jsonl&lt;/code&gt;. JSON Lines keeps it dependency-free; swap in SQLite (&lt;code&gt;better-sqlite3&lt;/code&gt;, or &lt;code&gt;node:sqlite&lt;/code&gt; on recent Node versions) when the file gets large.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// track.js
import { appendFile } from &quot;node:fs/promises&quot;;
import { scrapeProfile } from &quot;./profile.js&quot;;

const HANDLES = process.argv.slice(2);
if (!HANDLES.length) {
  console.error(&quot;usage: node track.js handle1 handle2 ...&quot;);
  process.exit(1);
}
const sleep = (ms) =&amp;gt; new Promise((r) =&amp;gt; setTimeout(r, ms));

const runAt = new Date().toISOString();
for (const handle of HANDLES) {
  try {
    const { followers, posts } = await scrapeProfile(handle);
    const lines = posts
      .filter((p) =&amp;gt; !p.isReply)
      .map((p) =&amp;gt; JSON.stringify({ runAt, handle, followers, ...p }));
    if (lines.length) await appendFile(&quot;snapshots.jsonl&quot;, lines.join(&quot;\n&quot;) + &quot;\n&quot;);
    console.log(`${handle}: ${lines.length} posts, ${followers ?? &quot;?&quot;} followers`);
  } catch (err) {
    console.error(`${handle}: ${err.message}`);
  }
  await sleep(4_000 + Math.random() * 3_000);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Schedule it with cron every few hours:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;0 */4 * * * cd /path/to/threads-scraper &amp;amp;&amp;amp; /usr/bin/node track.js handle1 handle2 &amp;gt;&amp;gt; track.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And a report that compares each post's first and latest snapshot:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// report.js
import { readFile } from &quot;node:fs/promises&quot;;

const rows = (await readFile(&quot;snapshots.jsonl&quot;, &quot;utf8&quot;))
  .split(&quot;\n&quot;)
  .filter(Boolean)
  .map((l) =&amp;gt; JSON.parse(l));

const byPost = new Map();
for (const r of rows) {
  const s = byPost.get(r.code) ?? { first: r, last: r };
  if (r.runAt &amp;lt; s.first.runAt) s.first = r;
  if (r.runAt &amp;gt; s.last.runAt) s.last = r;
  byPost.set(r.code, s);
}

const delta = (a, b) =&amp;gt; (a == null || b == null ? null : b - a);
const report = [...byPost.values()]
  .map(({ first, last }) =&amp;gt; ({
    post: last.url,
    snapshots: `${first.runAt.slice(0, 16)} -&amp;gt; ${last.runAt.slice(0, 16)}`,
    likes: last.likes,
    likeGain: delta(first.likes, last.likes),
    replyGain: delta(first.replies, last.replies),
    repostGain: delta(first.reposts, last.reposts),
  }))
  .sort((a, b) =&amp;gt; (b.likeGain ?? -1) - (a.likeGain ?? -1));

console.table(report.slice(0, 15));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One thing to keep in mind when reading this report: because you only see the first page of posts, older posts drop out of the snapshots once newer ones push them off. A post that stops appearing hasn't stopped growing; you just can't see it anymore. If you need to keep following specific posts, feed their URLs through &lt;code&gt;scrapePost()&lt;/code&gt; on each run instead.&lt;/p&gt;
&lt;h3 id=&quot;the-managed-alternative&quot;&gt;The managed alternative&lt;/h3&gt;
&lt;p&gt;If maintaining the parser becomes a chore, or you need more than the first page from datacenter IPs, &lt;a rel=&quot;nofollow&quot; href=&quot;https://scrapeatlas.com&quot;&gt;ScrapeAtlas*&lt;/a&gt; has Threads endpoints that return the same post objects (&lt;code&gt;like_count&lt;/code&gt;, &lt;code&gt;taken_at&lt;/code&gt;, &lt;code&gt;text_post_app_info.direct_reply_count&lt;/code&gt;, and so on). Authentication is an &lt;code&gt;x-api-key&lt;/code&gt; header:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const atlas = (path, params) =&amp;gt;
  fetch(`https://api.scrapeatlas.com/v1/threads/${path}?${new URLSearchParams(params)}`, {
    headers: { &quot;x-api-key&quot;: process.env.SCRAPEATLAS_API_KEY },
  }).then((r) =&amp;gt; r.json());

const profile = await atlas(&quot;profile&quot;, { handle: &quot;zuck&quot; });
console.log(profile.username, profile.follower_count, profile.is_verified);

const feed = await atlas(&quot;user/posts&quot;, { handle: &quot;zuck&quot;, since: &quot;2026-10-01&quot; });
console.log(feed.posts.map((p) =&amp;gt; [p.url, p.like_count, p.text_post_app_info.direct_reply_count]));
console.log(feed.coverage); // e.g. { complete: false, warnings: [&quot;posts_first_page_only&quot;], ... }

const thread = await atlas(&quot;post&quot;, { url: &quot;https://www.threads.com/@zuck/post/&amp;lt;code&amp;gt;&quot; });
console.log(thread.post.like_count, thread.comments.length);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To be clear about the limits: &lt;code&gt;user/posts&lt;/code&gt; is also first page only, which the response tells you in &lt;code&gt;coverage.warnings&lt;/code&gt;, so the tracker's blind spot is the same. &lt;code&gt;post&lt;/code&gt; takes &lt;code&gt;include_comments=false&lt;/code&gt; if you only want the post, and every endpoint accepts &lt;code&gt;trim=true&lt;/code&gt; for a compact projection. There are also &lt;code&gt;user/replies&lt;/code&gt;, &lt;code&gt;post/comments&lt;/code&gt;, &lt;code&gt;search&lt;/code&gt; (keyword search with &lt;code&gt;recent&lt;/code&gt;, &lt;code&gt;start_date&lt;/code&gt;, and &lt;code&gt;end_date&lt;/code&gt;), &lt;code&gt;search/users&lt;/code&gt;, and &lt;code&gt;search/tags&lt;/code&gt;. Search is the part you can't do logged-out on your own.&lt;/p&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Read the preloaded JSON, not the DOM.&lt;/strong&gt; Walk every &lt;code&gt;application/json&lt;/code&gt; script and match on post shape, not a fixed path.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dedupe by &lt;code&gt;code&lt;/code&gt;&lt;/strong&gt; and normalize &lt;code&gt;threads.net&lt;/code&gt; to &lt;code&gt;threads.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Expect the first page only&lt;/strong&gt; without logging in, and design your tracker around that.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Skip GraphQL &lt;code&gt;doc_id&lt;/code&gt; calls&lt;/strong&gt; unless you're ready to re-capture them every time Meta ships a new build.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Go slow and cache,&lt;/strong&gt; and treat a small 200 response as a block.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;For your own account,&lt;/strong&gt; use the official Threads API.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the extractor stops finding posts for you, leave a comment with what the page source looks like now and I'll update the post.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;*I build it.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31945/how-to-scrape-threads-with-node-js-profiles-posts-replies-and-an-engagement-tracker-2026</guid>
<pubDate>Wed, 21 Oct 2026 09:05:56 +0000</pubDate>
</item>
</channel>
</rss>