X API 按返回貼文收費,Timothy Kelvin 將篩選條件移入查詢
X's API bills every post it returns, so I moved my filters into the query 💸
Timothy Kelvin 將 X 熱門貼文工具的互動篩選條件移入 API 查詢,避免低於門檻的貼文被返回並計費。X 自助 API 每篇貼文收費 $0.005;作者以「AI agents」min_likes:300 測試,查詢返回 10 篇貼文,最低有 320 個 likes。
Six posts read, two kept, six paid for. One operator fixed that.
Hi! I'm Tim, a self-taught developer in Nairobi. I build small data tools that run as Apify Actors, serverless jobs that people pay for per result.
This week I built one that finds posts taking off on X for a topic, a list of accounts, or whatever is trending. The interesting part wasn't the code. It was the bill.
🚪 Why the official API
The first thing I checked was X's robots.txt. After the rules for named search engines and link previewers, it ends with:
User-agent: *
Disallow: /
Every other bot is told to stay out, and posts, search and like counts sit behind a login anyway. So the tool uses the official X API v2, with each user's own Bearer Token. Nobody hands over a password or cookies, and nothing needs a proxy.
💸 What a read costs
X's self-serve API is pay-per-use. You buy credits, and each call deducts what it returns. From X's pricing page in October 2026:
| What X returns | Price |
|---|---|
| A post | $0.005 |
| A user profile | $0.010 |
| A trends request | $0.010 |
Two details matter. A post is billed once per UTC day, so reading it again the same day is free (X calls this a "soft guarantee"). And pay-per-use is capped at 3 million post reads a month.
At $0.005 a post, the usual pattern of "fetch a lot, filter in code" stops being free.
🎯 Put the threshold in the query
My first version was going to fetch recent posts for a topic and keep the ones with enough likes. Every post it threw away would still be on the bill.
Then I found three operators in X's recent-search docs: min_likes:, min_reposts: and min_replies:. (According to the same docs, min_faves: and min_retweets: from X's web search are rejected.)
My first live call:
"AI agents" min_likes:300 -is:retweet -is:reply -is:nullcast lang:en
X returned 10 posts. The least-liked one had 320 likes. Nothing below the line came back, so nothing below the line was billed.
Every query the tool builds now ends with the user's filters, threshold included:
export function filterOperators({ contentType = 'all', minLikes = 0, minReposts = 0, minReplies = 0, language = null, includeReplies = false, verifiedOnly = false }) {
const ops = ['-is:retweet', '-is:nullcast']; // no reposts, no ads
if (!includeReplies) ops.push('-is:reply');
if (verifiedOnly) ops.push('is:verified');
if (language) ops.push(`lang:${language}`);
ops.push(...(CONTENT_OPERATORS[contentType] ?? [])); // e.g. has:video_link for native videos
if (minLikes > 0) ops.push(`min_likes:${minLikes}`);
if (minReposts > 0) ops.push(`min_reposts:${minReposts}`);
if (minReplies > 0) ops.push(`min_replies:${minReplies}`);
return ops.join(' ');
}
Leaving reposts out matters for the same reason. A repost comes back as a post of its own, so without -is:retweet you can pay for copies sitting next to the original.
👤 The author can cost more than the post
To get a post's author (handle, followers, verified badge), you add expansions=author_id, and the users come back in includes.users. X's price list puts a user profile at twice the price of a post.
I haven't yet confirmed in the console whether every expanded author is billed as a user read. Either way, I made author details a setting that's on by default and easy to switch off. Without it, a link becomes https://x.com/i/status/<id>, which still works.
📏 512 characters per query
The docs give self-serve recent search a 512-character query limit. That's plenty for a topic, but not for 60 accounts written as from: clauses, and each extra query is an extra request.
So accounts are packed into as few queries as fit, with the filters counted in:
let group = [];
for (const h of handles) {
const next = [...group, h];
if (`(${next.map((x) => `from:${x}`).join(' OR ')}) ${ops}`.length > MAX_QUERY_LENGTH) flush();
group.push(h);
}
flush();
60 handles became 3 queries, of 512, 503 and 256 characters, with every handle in exactly one of them. One query per handle would have been 60 requests.
🔗 Three kinds of link in one array
entities.urls mixes three things:
-
Attached media. Photos and videos show up as
x.com/.../photo/1or/video/1links, and these carry amedia_key. -
Quoted posts. A quote's link points back to
twitter.com/<user>/status/<id>. -
Real outside links. Often with
unwound_url,titleanddescriptionalready filled in by X.
For an "articles only" mode I query with has:links, then keep posts that link outside X (or to an X Article at x.com/i/article/...). In a test, X returned 10 posts for "AI agents" has:links min_likes:100, and 7 linked outside X. The other 3 were billed and dropped, which is the one place where filtering after reading is still unavoidable. The settings text says so.
📝 text is not the whole post
For posts over 280 characters, text is a shortened version. Ask for the note_tweet field and the full post is in note_tweet.text. On two long posts from my first call, text was 303 and 302 characters, and note_tweet.text was 464 and 311.
📈 Which number says "viral"?
Here are three posts from the same morning (2026-10-29), with the counts at the time I read them:
| Post | Likes | Views | Engagement rate | Likes per hour | Views per follower |
|---|---|---|---|---|---|
| SpaceX's Starship static-fire video | 17,631 | 1,854,564 | 1.17% | 875 | 0.04 |
| A 24-second meme video about AI agents, from an account with 80K followers | 10,823 | 505,501 | 2.81% | 565 | 6.30 |
| A long post about AI agents, from an account with 152K followers | 619 | 8,260 | 10.51% | 37 | 0.05 |
Sort by likes and SpaceX wins, which is what you'd expect from 42 million followers. Sort by views per follower and the meme video wins: it got six views for every follower its account has. Sort by engagement rate and the long post wins, because the few people who saw it acted on it.
None of those is "the" viral score, so I don't compute one. Each row carries all of them, and the user picks the sort.
🌍 Trends come back as names only
GET /2/trends/by/woeid/{woeid} lists what's trending in a place. I checked all 16 places I offer, and every one answered. But for worldwide, every trend came back without tweet_count, so you can't rank trends by volume. And two of the top five worldwide trends were Japanese hashtags, which is why trend searches respect the language filter too.
🧾 No key, no crash
Apify runs every public Actor once a day with its example input, and flags it after repeated failures. A secret token can't be part of that example, so a run without one has to succeed anyway. It saves one row explaining how to get a token, charges nothing and exits cleanly. A bad token, an account with no credits, or an app without read access each get their own message (401, 402 and 403), and any posts already found are still saved.
🎯 What I learned
-
When reads cost money, the filter belongs in the request. With
min_likes:in the query, every post I pay for is one I keep. - Read the price list before the endpoint docs. It changed what I fetched, and in what order.
- Expansions aren't free extras. Make the expensive ones optional.
- "Viral" isn't one number. Big accounts win on raw counts, and small ones win on reach and rate. Give people the columns and let them sort.
- Check robots.txt first. It settled the whole design in two lines.
Testing all of this cost me under a dollar of X credits.
來源:DEV Community · dev.to
