跳到正文
DEV Community· Viktor·· 3 小時前AI 評分42

Linktree:如何用 Node.js 擷取個人頁面、連結與社交圖示並追蹤變更(2026)

How to Scrape Linktree with Node.js: Profiles, Links, Socials, and a Link-Change Tracker (2026)

AI 導讀

教學示範如何用 Node.js 讀取 Linktree 公開個人頁面,整理頁面資料、連結及社交圖示,並追蹤連結新增、移除、改名或改向。程式只需一個檔案、毋須 npm 依賴,使用 Node 22.5+ 內置的 node:sqlite 儲存快照。Linktree 條款禁止以自動化方式收集個人頁面內容,文中提醒執行前須評估條款及法律風險。

正文

Linktree pages are everywhere. Creators, bands, podcasts, shops, and plenty of brands put one linktr.ee/... URL in their bio and send everything through it. So if you manage a few creators, run an agency, or just want to know when your own page or a client's page changes, it's useful to read those pages as data: who the profile is, which links it shows, in what order, and which social icons it lists.

People usually start by searching for "Linktree API". There is official tooling, but it's built for managing pages you own, not for reading other people's pages. This post covers both: what Linktree officially offers, and how to build a small Linktree reader in Node.js for public profile pages. It's one file, no npm dependencies, and it uses Node's built-in node:sqlite (Node 22.5+). It will:

  1. Fetch a profile page politely and treat 404s and blocks properly
  2. Pull the page data out of the Next.js __NEXT_DATA__ script
  3. Turn that into one clean object: profile, ordered links, and social icons
  4. Store snapshots in SQLite and report which links were added, removed, retitled, or re-pointed
  5. Check where every link actually goes, so you catch dead links

Everything below was run on November 4, 2026, against a few well-known public profiles. The output is real.

Read this first. Linktree's Terms of Service say that you must not, as a user, a profile visitor, or in any other capacity, use scripts, bots, scraping tools, or other automated means to access, download, extract, aggregate, or otherwise collect content or information from Linktree user profiles or the platform. The robots.txt agrees: it names a list of search and AI crawlers, and everything else falls under User-agent: * / Disallow: /, with the comment "Fallback, disallow scraping". Your script falls under that rule. So running this code against Linktree profiles goes against their terms, and you should review those terms and your legal risk before you run anything. Where an official option covers your need (see the next section), use it instead. If you go ahead anyway, keep it to a handful of pages you have a real reason to read (your own, or a client's with their permission), go slowly, don't republish or resell what you collect, and don't build contact lists from people's pages.

Is there a Linktree API?

Sort of. I checked what Linktree actually publishes, and found three things:

  • The Linktree MCP server (https://mcp.linktr.ee/mcp, tool reference at mcp.linktr.ee/docs). It's listed in Linktree's own /.well-known/api-catalog. It uses OAuth 2.0, and its tools act on profiles you have access to: get_account, get_links, add_link, update_link, move_link, analytics, and so on. If you want to read or edit your own Linktree (or the ones in your workspace) from code or an AI agent, start here.
  • The LinkApp SDK (docs.linktr.ee, npx @linktr.ee/create-linkapp). This is for building interactive "LinkApps" that appear on Linktree profiles. It's not a data API.
  • A developer program waitlist at linktr.ee/marketplace/developer: "We're expanding access to our APIs and SDKs. Register your interest..."

What I did not find is a public, read-only REST API where you pass any username and get back its links. So:

You want Use
Read or update your own profile, links, and analytics Linktree MCP server (OAuth) or the admin dashboard
Build something that shows up on Linktree profiles LinkApp SDK
Read someone else's public profile No official option. That's what the code below does, and it's what the terms forbid.

What a Linktree profile page looks like to a script

I fetched a few profiles with curl and Node's fetch first:

path                                      status  bytes     notes
/robots.txt                               200               named bots allowed, "*" disallowed
/tonyhawk                                 200     313,189   full profile in __NEXT_DATA__
/TonyHawk                                 200               usernames are case-insensitive
/billieeilish                             200     322,229   6 links, 6 social icons
/spotify                                  200     241,608   6 playlist links
/linktree                                 200      86,219   one "forwarding" link
/thisusernamedoesnotexist12345            404      12,448   page is "/_error"
/_next/data/<buildId>/tonyhawk.json       404               the Next.js JSON route is not exposed

The status codes were the same with an honest User-Agent, a Chrome User-Agent, and curl's default. I saw no rate-limit headers. I also didn't try to find the limit, and you shouldn't either.

Three things shaped the code.

1. Everything is in one JSON script. linktr.ee is a Next.js app. The server renders the page and puts its data in <script id="__NEXT_DATA__" type="application/json">. For a profile, data.page is "/[profile]" and data.props.pageProps holds about 80 keys. The useful ones:

pageProps.account        id, uuid, username, pageTitle, description, profilePictureUrl,
                         profileBadges, verticals, timezone, tier, createdAt, updatedAt, ...
pageProps.links          [{ id, type, title, url, position, parent, children, rules, modifiers, metaData, ... }]
pageProps.socialLinks    [{ type, url, position }]
pageProps.isProfileVerified, hasSensitiveContent, hasPagePassword, pageLockType, contentGating

So you don't need a headless browser. One request per profile gives you everything shown on the page.

2. Links aren't all URLs. On Billie Eilish's page, four links are normal (CLASSIC and YOUTUBE_VIDEO), but "SHOP MERCH" is a PRODUCT link and the mailing-list signup is an EXTENSION. Both have an empty url. They are app-style blocks that render inside Linktree.

3. position has gaps. Tony Hawk's 12 visible links have positions 0, 3, 10, 11, 12, 13, 38, 42, 44, 61, 77, 83. My guess is that hidden, scheduled, or archived links keep their slots but aren't sent to visitors. Sort by position, but don't treat it as a 1-to-N rank.

Step 1: A polite fetch helper

One profile every four seconds, an honest User-Agent, 404 means "no such profile", and 403 or 429 means stop.

// linktree.mjs - a small Linktree profile reader + link-change tracker in Node.js.
// Node 22.5+ (uses the built-in node:sqlite), no npm dependencies.
// Read Linktree's terms first: they prohibit automated collection from profiles.
import { DatabaseSync } from "node:sqlite";

const BASE = "https://linktr.ee";
const UA = process.env.LT_UA ?? "linktree-research-script/1.0 (+mailto:you@example.com)";
const MIN_DELAY_MS = 4000; // one profile every ~4 s, at most

// ---------- Step 1: a polite fetch helper ----------
let last = 0;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function get(url) {
  const wait = last + MIN_DELAY_MS - Date.now();
  if (wait > 0) await sleep(wait);
  last = Date.now();
  const res = await fetch(url, { headers: { "user-agent": UA, accept: "text/html" }, redirect: "follow" });
  if (res.status === 404) return null; // no such profile (or it was deleted)
  if (res.status === 429 || res.status === 403) {
    throw new Error(`${res.status} from ${url}: you are being limited. Stop and try again much later.`);
  }
  if (!res.ok) throw new Error(`${res.status} from ${url}`);
  return res.text();
}

Two small choices here. The User-Agent says what the script is, with a way to contact you. And there's no retry loop on 429. If Linktree starts limiting you, the right move is to stop, not to retry faster.

Step 2: Pull out __NEXT_DATA__

// ---------- Step 2: pull the Next.js page data out of the HTML ----------
function nextData(html) {
  const m = html.match(/<script id="__NEXT_DATA__" type="application\/json"[^>]*>([\s\S]*?)<\/script>/);
  if (!m) throw new Error("no __NEXT_DATA__ script: the page shape changed");
  return JSON.parse(m[1]);
}

const iso = (ms) => (typeof ms === "number" ? new Date(ms).toISOString() : null);

// Which gates are switched on for a link (age, passcode, sensitive-content, ...).
function gates(link) {
  const g = link.rules?.gate ?? {};
  const on = Object.entries(g)
    .filter(([k, v]) => k !== "activeOrder" && k !== "sensitiveContent" && v)
    .map(([k]) => k);
  if (g.sensitiveContent?.domain) on.push("sensitiveContent");
  return on;
}

function normLink(l, parentId = null) {
  return {
    id: String(l.id),
    type: l.type, // CLASSIC, YOUTUBE_VIDEO, PRODUCT, EXTENSION, ...
    title: (l.title ?? "").replace(/\s+/g, " ").trim(),
    url: l.url || null, // empty for app-style links (PRODUCT, EXTENSION, ...)
    position: l.position,
    parentId: l.parent?.id ? String(l.parent.id) : parentId,
    forwarding: Boolean(l.modifiers?.isForwardingActive),
    gated: gates(l),
    locked: Boolean(l.locked),
  };
}

The regex matches the exact script tag Next.js writes. If Linktree moves to the App Router one day, this tag will disappear and the data will move into React Server Component chunks (self.__next_f.push(...)). That's why the function fails with a clear message instead of returning an empty profile. An empty result that looks valid is worse than an error.

Step 3: One profile, one clean object

The raw link objects have about 20 keys each, most of them null. This keeps the parts you'll use and flattens grouped links (children) so each one has a parentId.

// ---------- Step 3: one profile -> one clean object ----------
export async function profile(username) {
  const html = await get(`${BASE}/${encodeURIComponent(username)}`);
  if (!html) return null;
  const data = nextData(html);
  if (data.page !== "/[profile]") return null; // e.g. "/_error"
  const p = data.props?.pageProps;
  const a = p?.account;
  if (!a || !Array.isArray(p.links)) throw new Error("profile JSON without account/links: the shape changed");

  const links = [];
  for (const l of p.links) {
    links.push(normLink(l));
    for (const c of l.children ?? []) links.push(normLink(c, String(l.id))); // grouped links
  }

  return {
    username: a.username,
    id: a.id,
    url: `${BASE}/${a.username}`,
    displayName: a.pageTitle ?? null,
    bio: a.description ?? null,
    verified: Boolean(p.isProfileVerified),
    badges: a.profileBadges ?? [],
    verticals: a.verticals ?? [],
    timezone: a.timezone ?? null,
    tier: a.tier ?? null,
    createdAt: iso(a.createdAt),
    updatedAt: iso(a.updatedAt),
    sensitive: Boolean(p.hasSensitiveContent),
    pageLocked: Boolean(p.hasPagePassword || p.pageLockType),
    socials: (p.socialLinks ?? []).map((s) => ({ type: s.type, url: s.url })),
    links,
    fetchedAt: new Date().toISOString(),
  };
}

A few details from the real data:

  • pageTitle is the display name, and it's whatever the owner typed. Tony Hawk's is @tonyhawk, Billie Eilish's is Billie Eilish.
  • account.username keeps the owner's casing. Billie Eilish's comes back as Billieeilish. The tracker lowercases it.
  • createdAt and updatedAt are epoch milliseconds. updatedAt changes when the owner edits the profile, which makes it a cheap "did anything change?" signal.
  • tier comes back as free, pro, or paid2. That's an internal value. Don't build anything on it.
  • Gates. Each link has rules.gate with age, passcode, password, nft, and sensitiveContent. None of my test links had a gate switched on, so I can't show you a gated link. The code only records which gates are on. It doesn't try to get past them, and you shouldn't either. Gated links, page passwords (hasPagePassword), and sensitive-content profiles (hasSensitiveContent) are the owner saying "not for everyone".

Here's part of node linktree.mjs profile tonyhawk:

{
  "username": "tonyhawk",
  "id": 8900221,
  "url": "https://linktr.ee/tonyhawk",
  "displayName": "@tonyhawk",
  "bio": null,
  "verified": true,
  "badges": ["VERIFIED_AND_NOTABLE_TICK"],
  "verticals": [],
  "timezone": "America/Los_Angeles",
  "tier": "pro",
  "createdAt": "2020-09-22T17:15:38.000Z",
  "updatedAt": "2026-09-13T06:54:58.000Z",
  "sensitive": false,
  "pageLocked": false,
  "socials": [],
  "links": [
    {
      "id": "582852123",
      "type": "CLASSIC",
      "title": "Order “Nearshore” by Steve Hawk here",
      "url": "https://stevehawkauthor.com",
      "position": 0,
      "parentId": null,
      "forwarding": false,
      "gated": [],
      "locked": false
    },
    {
      "id": "449921776",
      "type": "YOUTUBE_VIDEO",
      "title": "My final video",
      "url": "https://youtu.be/KgyW24ONcUI?si=3NPP0immuN4fTTU2",
      "position": 3,
      "parentId": null,
      "forwarding": false,
      "gated": [],
      "locked": false
    }
  ]
}

(10 more links trimmed.) And the parts of Billie Eilish's page that show the other link types and the social icons:

socials: INSTAGRAM https://instagram.com/billieeilish
         TIKTOK    https://tiktok.com/@billieeilish
         SPOTIFY   https://open.spotify.com/artist/6qqNVTkY8uBg9cP3Jd7DAH
         YOUTUBE   https://www.youtube.com/billieeilish
         APPLE_MUSIC https://music.apple.com/us/artist/billie-eilish/1065981054
         X         https://x.com/billieeilish

links:   CLASSIC        WATCH HIT ME HARD AND SOFT: THE TOUR AT HOME   http://billieeilish.lnk.to/ondemand
         YOUTUBE_VIDEO  WATCH THE HIT ME HARD AND SOFT: THE TOUR ...   https://www.youtube.com/watch?v=WY1zGdMyWZQ
         CLASSIC        LISTEN TO HIT ME HARD AND SOFT                 https://billieeilish.lnk.to/HITMEHARDANDSOFT
         CLASSIC        SHOP EILISH FRAGRANCES                         https://billieeilish.lnk.to/shopfragrance
         PRODUCT        SHOP MERCH                                     (no url)
         EXTENSION      SIGN UP FOR BILLIE'S MAILING LIST              (no url)

The raw "SHOP MERCH" title has 36 spaces in the middle. That's why normLink collapses whitespace.

Step 4: A SQLite link-change tracker

Each run saves a snapshot: one row in snapshots, and one row in items per link and per social icon. Then it compares the two newest snapshots for that profile.

// ---------- Step 4: snapshots in SQLite ----------
export function openDb(file = "linktree.db") {
  const db = new DatabaseSync(file);
  db.exec(`
    CREATE TABLE IF NOT EXISTS snapshots (
      id INTEGER PRIMARY KEY, username TEXT NOT NULL, fetched_at TEXT NOT NULL,
      display_name TEXT, bio TEXT, verified INTEGER, n_links INTEGER, n_socials INTEGER);
    CREATE TABLE IF NOT EXISTS items (
      snapshot_id INTEGER NOT NULL REFERENCES snapshots(id), kind TEXT NOT NULL, -- 'link' | 'social'
      key TEXT NOT NULL, type TEXT, title TEXT, url TEXT, position INTEGER);
    CREATE INDEX IF NOT EXISTS items_snap ON items(snapshot_id);`);
  return db;
}

function save(db, p) {
  const { lastInsertRowid: sid } = db
    .prepare(`INSERT INTO snapshots (username, fetched_at, display_name, bio, verified, n_links, n_socials)
              VALUES (?, ?, ?, ?, ?, ?, ?)`)
    .run(p.username.toLowerCase(), p.fetchedAt, p.displayName, p.bio, p.verified ? 1 : 0, p.links.length, p.socials.length);
  const ins = db.prepare(`INSERT INTO items VALUES (?, ?, ?, ?, ?, ?, ?)`);
  for (const l of p.links) ins.run(sid, "link", l.id, l.type, l.title, l.url, l.position);
  for (const s of p.socials) ins.run(sid, "social", s.type, s.type, null, s.url, null);
  return Number(sid);
}

function items(db, sid) {
  const rows = db.prepare(`SELECT kind, key, type, title, url FROM items WHERE snapshot_id = ?`).all(sid);
  return new Map(rows.map((r) => [`${r.kind}:${r.key}`, r]));
}

// Compare the two newest snapshots of a profile.
export function diff(db, username) {
  const [now, prev] = db
    .prepare(`SELECT id, fetched_at FROM snapshots WHERE username = ? ORDER BY id DESC LIMIT 2`)
    .all(username.toLowerCase());
  if (!prev) return null;
  const a = items(db, prev.id), b = items(db, now.id);
  const changes = [];
  for (const [k, r] of b) {
    const o = a.get(k);
    if (!o) changes.push({ change: "added", kind: r.kind, title: r.title ?? r.type, url: r.url });
    else if (o.url !== r.url) changes.push({ change: "url", kind: r.kind, title: r.title ?? r.type, from: o.url, to: r.url });
    else if (o.title !== r.title) changes.push({ change: "title", kind: r.kind, from: o.title, to: r.title });
  }
  for (const [k, o] of a) if (!b.has(k)) changes.push({ change: "removed", kind: o.kind, title: o.title ?? o.type, url: o.url });
  return { since: prev.fetched_at, changes };
}

Links are matched by their Linktree id, and social icons by their type (one Instagram icon per profile). Matching by id means a retitled link shows up as "title changed", not as one removal plus one addition. Matching by URL would get that wrong.

Here are the first two real runs. Each profile is one request, four seconds apart:

$ node linktree.mjs track billieeilish tonyhawk spotify linktree
Billieeilish: first snapshot, 6 links, 6 socials
tonyhawk: first snapshot, 12 links, 0 socials
spotify: first snapshot, 6 links, 0 socials
linktree: first snapshot, 1 links, 0 socials

$ node linktree.mjs track billieeilish tonyhawk
Billieeilish: no changes since 2026-11-04T04:05:54.546Z
tonyhawk: no changes since 2026-11-04T04:05:57.430Z

Nobody edits their Linktree in the 20 seconds between two test runs. So to test the diff, I edited the stored snapshot by hand: I deleted one link, changed one URL, shortened one title, and added a fake "Summer tour dates" link. Then I ran it again against the live page:

$ node linktree.mjs track tonyhawk
tonyhawk: 4 change(s) since 2026-11-04T04:06:13.199Z
   {"change":"added","kind":"link","title":"Order “Nearshore” by Steve Hawk here","url":"https://stevehawkauthor.com"}
   {"change":"url","kind":"link","title":"New Birdhouse shirts & hoodies","from":"https://www.birdhouseskateboards.com/collections/sale","to":"https://www.birdhouseskateboards.com/collections/apparel"}
   {"change":"title","kind":"link","from":"Book me","to":"Book me on Cameo"}
   {"change":"removed","kind":"link","title":"Summer tour dates","url":"https://example.com/tour"}

All four kinds of change were detected. Run it once a day from cron for the few profiles you care about, and you'll have a history of what was promoted and when.

Step 5: Check where the links go

This is the part I find most useful for my own pages. Link-in-bio pages collect dead links over time, because a product sells out or a campaign page gets removed, and nobody notices.

// ---------- Step 5: check where the links actually go ----------
export async function checkLinks(p, { delayMs = 1500 } = {}) {
  const out = [];
  for (const l of p.links.filter((x) => x.url)) {
    let status, finalUrl;
    try {
      // GET, not HEAD: several shops and link shorteners answer HEAD with 403/405.
      const r = await fetch(l.url, { headers: { "user-agent": UA }, redirect: "follow", signal: AbortSignal.timeout(15000) });
      status = r.status; finalUrl = r.url;
      await r.body?.cancel();
    } catch (e) {
      status = e.name === "TimeoutError" ? "timeout" : "error";
    }
    out.push({ title: l.title, url: l.url, status, finalHost: finalUrl ? new URL(finalUrl).host : null });
    await sleep(delayMs);
  }
  return out;
}

These requests go to the link targets, not to Linktree. Keep them slow anyway. The real result for Tony Hawk's page:

$ node linktree.mjs check tonyhawk
200      www.stevehawkauthor.com      Order “Nearshore” by Steve Hawk here
200      www.youtube.com              My final video
200      www.birdhouseskateboards.com New Birdhouse shirts & hoodies
200      www.birdhouseskateboards.com Birdhouse completes
200      www.cameo.com                Book me on Cameo
200      www.youtube.com              WAR CRY video
404      www.tonyhawk.com             Signed Hawk Vs Wolf decks
200      www.youtube.com              Thrasher “Masher” Houston
200      www.youtube.com              Thrasher “Masher” from Baja
200      www.youtube.com              Masher (Hawaii edit) on Thrasher
200      www.youtube.com              Birdhouse's “Skate and Create”
200      www.youtube.com              Learning a "dream trick" / NBD

One real dead link: the signed "Hawk Vs Wolf" deck product page returns 404. I checked it with a browser User-Agent as well, and it's still a 404. That's exactly the kind of thing this check is for. (If it's your page, the fix takes 10 seconds in the admin.)

finalHost matters for short links. Billie Eilish's links go through lnk.to, a smart-link service that redirects by country. The status you see is that of the final page, so a 200 means "this lands somewhere", not "this lands where it did last month".

The CLI

// ---------- CLI ----------
async function main() {
  const [cmd, ...users] = process.argv.slice(2);
  if (cmd === "profile") {
    for (const u of users) console.log(JSON.stringify((await profile(u)) ?? { username: u, found: false }, null, 2));
  } else if (cmd === "track") {
    const db = openDb(process.env.LT_DB ?? "linktree.db");
    for (const u of users) {
      const p = await profile(u);
      if (!p) { console.log(`${u}: not found`); continue; }
      save(db, p);
      const d = diff(db, u);
      if (!d) console.log(`${p.username}: first snapshot, ${p.links.length} links, ${p.socials.length} socials`);
      else if (!d.changes.length) console.log(`${p.username}: no changes since ${d.since}`);
      else { console.log(`${p.username}: ${d.changes.length} change(s) since ${d.since}`); for (const c of d.changes) console.log("  ", JSON.stringify(c)); }
    }
  } else if (cmd === "check") {
    const p = await profile(users[0]);
    if (!p) return console.log("not found");
    for (const r of await checkLinks(p)) console.log(String(r.status).padEnd(8), r.finalHost?.padEnd(28) ?? "".padEnd(28), r.title);
  } else {
    console.log("usage: node linktree.mjs profile <user...> | track <user...> | check <user>");
  }
}

if (import.meta.url === `file://${process.argv[1]}`) main().catch((e) => { console.error(e.message); process.exit(1); });
node linktree.mjs profile tonyhawk > tonyhawk.json
node linktree.mjs track billieeilish tonyhawk spotify   # run daily
node linktree.mjs check tonyhawk

Pitfalls, in one place

  • The terms and robots.txt both say no. Linktree's terms ban automated collection from profiles, and robots.txt disallows / for every crawler it doesn't name. For your own pages, use the MCP server or the dashboard.
  • A brand's name isn't its username. linktr.ee/nike is not Nike. It's a personal page with three links to someone's Instagram, SoundCloud, and TikTok. Verify the profile (isProfileVerified, profileBadges, and links back from the brand's own site) before you trust it.
  • Forwarding profiles. linktr.ee/linktree has one link, "Linktree's Linktree", with isForwardingActive: true, pointing to linktr.ee/linktr.ee. Visitors get sent straight there. The HTML still returns 200 with the data, so check the forwarding flag. Otherwise you'll record a one-link profile and wonder why.
  • __NEXT_DATA__ is a private contract. Key names like isForwardingActive, profileBadges, or tier can be renamed in any deploy. Validate the shape (the code throws if account or links is missing) and keep a saved HTML page so you can compare when something breaks.
  • No JSON shortcut. The usual Next.js trick, /_next/data/<buildId>/<page>.json, returns 404 here. Parse the HTML.
  • Empty URLs are normal. PRODUCT, EXTENSION, and other app blocks have no url. Don't count them as broken links.
  • Positions have gaps. Sort by position, don't rank by it.
  • Usernames are case-insensitive but keep their casing. TonyHawk and tonyhawk are the same page. Lowercase your keys.
  • Respect gates and sensitive pages. Age gates, passcodes, page passwords, and sensitive-content flags are there for a reason. Record that they exist and move on.
  • No visible rate-limit headers doesn't mean no limit. Linktree sits behind a CDN. At one request every four seconds for a handful of profiles I never saw a block, but I didn't push it and you shouldn't. The code stops on the first 403 or 429.
  • Social icons are not links. They live in socialLinks, separate from links, and many big profiles (Tony Hawk, Spotify) have none.

When DIY stops being worth it

The single file is fine for a few profiles you check once a day. It stops being fun when you need the shop tab, need to discover profiles by category, or have to keep up with every change to the page JSON.

For that I use ScrapeAtlas*. It has three Linktree endpoints on https://api.scrapeatlas.com, with auth through an x-api-key header:

  • GET /v1/linktree?url=https://linktr.ee/<username> returns a public profile with display name, verification, badges, ordered links, block previews, and social icons.
  • GET /v1/linktree/shop?url=... returns the profile's published shop products and collections with prices, images, and vendors.
  • GET /v1/linktree/directory?category=tech&page=1 browses Linktree's creator directory by category (business, creative, education, tech, and so on) or subcategory, with display names, verification, avatars, and pagination.

Each response has a coverage object that tells you whether the result is complete. A successful request costs 1 credit, and failed requests cost nothing.

For your own page, the official MCP server is still the right tool. For a handful of profiles, the script above works.

Wrapping up

  • Linktree's official tools are for your own pages. The MCP server reads and edits profiles you have access to, and the LinkApp SDK builds blocks. There's no public read API for other profiles.
  • The terms and robots.txt forbid automated collection. Know that before you run anything.
  • One request gives you the whole page. __NEXT_DATA__ has the account, ordered links, and social icons.
  • Track by link id. Then retitles and re-pointed URLs show up as what they are.
  • The dead-link check pays off quickly. On the first real run, it found a 404 on a verified profile.

Which would you use more: the change tracker, or the dead-link check on your own page? Tell me in the comments.


*I build it.

來源:DEV Community · dev.to