Threads 不存在的用戶名稱回傳 200 OK,並提供 351 KB 登入頁面
A Threads Username That Doesn't Exist Returns 200 OK and 351 KB of Login Page
Threads 對不存在的用戶名稱回傳 HTTP 200,提供 351 KB 登入頁面,而非 404;頁面沒有 Relay payload,舊邏輯因此會重試三次。作者改以 canonical URL 識別登入頁,並要求兩個代理工作階段確認,避免為同一結果多次等待退避時間。
I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them is deliberately boring: one Threads username in, one row out — followers, full name, bio, the links and contacts published in it, verification, the linked Instagram, and the latest post with its engagement. No batch job, no thousand posts I did not ask for. That shape exists because of how it gets called: an agent asks about one account, an n8n node fires once per row, a CRM enrichment runs per contact.
For a shape like that, latency is the product. So on 24 October 2026 I timed it, and the real accounts were fine — and the typos were not.
| Input | Requests to Threads | Bytes pulled | Run time |
|---|---|---|---|
zuck |
1 | 1,190,211 | 3.9 s |
natgeo |
1 | 858,228 | 4.6 s |
| a username nobody owns | 3 | 1,057,628 | 14.3 s |
| the same username again | 3 | 1,057,629 | 14.6 s |
| the same username, earlier | 3 | 936,153 | 21.2 s |
Three requests and up to twenty-one seconds to learn that an account does not exist, against one request and four seconds to return a profile that does. Nothing was billed for the misses — a username that is missing, private or renamed has always been a free error row here — so this never showed up as money. It showed up as an agent waiting.
What Threads actually serves for a username nobody owns
There is no 404. There is no redirect to an error page. There is a 200 and a document:
@zuck |
@malinavisa-no-such-acct-9q7x |
|
|---|---|---|
| HTTP status | 200 | 200 |
| Bytes | 1,201,337 | 351,710 |
RelayPrefetchedStreamCache blocks |
2 | 0 |
"Sorry, this page isn't available" |
absent | absent |
| LSD token present | yes | yes |
<title> |
Mark Zuckerberg (@zuck) • Threads, Say more |
Threads • Log in |
og:url |
https://www.threads.com/@zuck |
https://www.threads.com/login |
A logged-out visitor asking for an account that exists gets the profile as JSON embedded in the HTML — Meta's Relay payloads, the same ones the React app would hydrate from. A visitor asking for an account that does not exist gets the login page, rendered under the profile URL, with a perfectly valid LSD token and no payloads at all.
Two details make this harder than it sounds.
First, threads.net now 301s to threads.com (0.16 s per hop, measured), and somewhere in that migration the old sentinel went away. My parser had been looking for Sorry, this page isn't available since September; today's HTML does not contain that string for a missing account, or any other apology.
Second, you cannot see any of this with curl. Following redirects from a datacenter IP, curl -L returned 231,125 bytes for both the real and the fake account — the same stripped shell, zero Relay payloads, differing only in their tokens. The embedded profile JSON only appears when the request carries a browser TLS fingerprint; this Actor uses impit with Chrome's. So the naive existence check — "did the page have data in it?" — comes back false for a real account too if your client looks wrong, which is exactly why my code did what it did next.
The retry budget landed on the wrong branch
Here is the old loop, and it is not stupid — it is just answering a different question than the one in front of it:
for (let attempt = 1; attempt <= 3; attempt++) {
const html = await request(url, { headers: pageHeaders }, 'page');
parsed = parsePage(html, url);
if (Object.keys(parsed.payloads).length || /Sorry, this page isn't available/i.test(html)) return parsed;
// A page without Relay payloads is Threads serving a stripped shell — rotate the session and retry.
session += 1; stats.retries += 1;
await sleep(1200 * attempt);
}
A page with no payloads genuinely is, most of the time, Threads throttling an exit address: you rotate the proxy session and try again, and the second or third attempt comes back full. With the sentinel gone, a missing username enters the same branch and runs the whole budget — three fetches of 351 KB each, plus 1.2 s and 2.4 s of backoff — before anyone concludes anything. Twelve to nineteen seconds of work phase in the logs, all of it spent proving the same negative three times.
The block detector one layer down had quietly rotted too. It looked for "login_form" or <title>Log in in the first 4,000 characters of the response. The string login_form does not occur anywhere in today's HTML, and <title> sits 98,841 bytes into the document, so that slice never reached it. Blocks were still caught — by their 429 and 403 status codes — but the content-based check had been dead for a while without failing loudly. That is the quiet kind of rot: a condition that cannot fire looks identical to a condition that never needed to.
The marker that survived
Everything cheap about that document had moved, except one thing: the canonical URL is in the first kilobyte of head. Byte 2,141 for the login shell, byte 2,253 for the profile.
/** Threads serves a username that does not exist as its own login page with HTTP 200. The page's
* <title> sits ~99 KB into the HTML, but its canonical URL is in the first kilobyte and points at
* /login instead of /@username — the one crisp marker left after the threads.com migration. */
const LOGIN_WALL = /<meta property="og:url" content="https:\/\/www\.threads\.(?:net|com)\/login"/i;
export const isLoginWall = (html = '') => LOGIN_WALL.test(String(html).slice(0, 8000));
And in the page loop, the login wall gets its own branch:
if (parsed.loginWall) {
if (attempt >= 2) return parsed; // two exit addresses agreed: no such account
session += 1; stats.retries += 1;
continue; // no backoff — the answer is not going to change
}
Why two attempts instead of one, when I have a crisp marker? Because I cannot prove that a block never looks like this. A 429 and a 403 are caught above this code, but if Threads ever decides to answer a throttled address with its login page under a 200, a one-shot check would convert a temporary block into a confident "this account does not exist" — a wrong error row is worse than a slow correct one. Two independent proxy sessions have to agree, and the agreement costs one extra fetch and no sleeping.
Before and after, same username, same afternoon
| Build | Requests | Retries | Bytes | Work phase | Run time |
|---|---|---|---|---|---|
| 0.0.26, old | 3 | 3 | 1,057,628 | 11.8 s | 14.3 s |
| 0.0.26, old | 3 | 3 | 1,057,629 | 12.3 s | 14.6 s |
| 0.0.28, fixed | 2 | 1 | 705,057 | 3.4 s | 4.6 s |
| 0.0.28, fixed | 2 | 1 | 705,088 | 4.0 s | 5.1 s |
| 0.0.28, fixed | 2 | 1 | 705,115 | 5.0 s | 7.5 s |
The work phase — container boot excluded, the part that is my code talking to Threads — went from 11.8–19.1 s down to 3.1–5.0 s. Real profiles are untouched: one request, 1.19 MB, same row, same four seconds. The error row itself is byte-identical to before, which was a requirement, not a bonus: anyone matching on that string in production should not have to care that the plumbing changed.
{
"type": "error",
"username": "malinavisa-no-such-acct-9q7x",
"profileUrl": "https://www.threads.net/@malinavisa-no-such-acct-9q7x",
"error": "account not found, private, or renamed — check the username",
"blocked": false,
"scrapedAt": "2026-10-24T13:15:26.018Z"
}
A profile that does exist comes back as one flat row, trimmed here to the fields people actually use:
{
"type": "profile",
"username": "mkbhd",
"fullName": "Marques Brownlee",
"followerCount": 1710708,
"biography": "I promise I won't overdo the (text) filters.",
"bioLinks": ["https://mkbhd.com/"],
"emails": [], "phones": [],
"isVerified": true, "isPrivate": false,
"instagramUrl": "https://www.instagram.com/mkbhd/",
"latestPostAt": "2026-10-19T16:01:41.000Z",
"latestPostUrl": "https://www.threads.net/@mkbhd/post/Dero8yTm4Og",
"latestPostLikes": 4208,
"latestPostReplies": 118,
"scrapedAt": "2026-10-24T13:10:07.137Z"
}
Calling it is one request, and the free summary row at the end tells you what was charged before the billing tab does:
curl -X POST "https://api.apify.com/v2/acts/lergassy~threads-profile-api/run-sync-get-dataset-items?token=<TOKEN>" \
-H "Content-Type: application/json" \
-d '{"username":"natgeo","latestPosts":0}'
Filter type != "notice" and you have your data. The listing is Threads Profile API.
When this is the wrong tool
If you have ten thousand usernames in a spreadsheet, the per-call shape is the wrong shape — one run with a list is the same money here (profiles bill per row, no start fee) but a bulk Actor is built for that queue. If you want an account's posts rather than its profile, this returns the latest one plus up to 25 on request, and nothing deeper. And private accounts are not solvable by any amount of retrying: Threads shows a logged-out visitor the same login page it shows for a name nobody owns, which is precisely why both come back under one honest error message instead of an invented profile.
The general lesson is cheaper to learn from someone else's logs than your own: when a site has no 404, your "I am blocked" branch and your "it does not exist" branch receive the identical response, and whichever one you wrote first will absorb the other. Mine absorbed it silently, for free, in seconds nobody was billed for — and seconds are the whole value of a one-row lookup.
來源:DEV Community · dev.to