一条推文的 JSON 里到底有什么(逐字段拆解)
一条推文的 JSON 里到底有什么(逐字段拆解)
请求发出去了,响应拿到了,然后你盯着一个三十来个键的对象,不知道哪些能信。
推文 JSON 真正的麻烦不是复杂,是有条件:一半字段大多数时候是 null;有三个字段看起来在说同一件事;还有一个会根据是不是转推悄悄换形状。
这篇用真实响应把它们全部走一遍——每个字段装什么、什么时候是 null、以及解析器具体会在哪几处崩掉。
下面所有响应都是写这篇时现场调出来的。想自己摸一遍这些结构,有个免费 demo 端点,不用注册。
先看整体结构
一个推文对象的字段分四组,分清楚之后后面就好读了:
| 分组 | 字段 | 可靠性 |
|---|---|---|
| 身份 | id、url、conversation_id、created_at |
永远存在 |
| 内容 | text、lang、source |
永远存在 |
| 计数 | like_count、retweet_count、reply_count、quote_count、bookmark_count、view_count |
永远存在,经常是 0 |
| 关系 | is_reply、is_retweet、is_quote_status 及其 *_id 搭档 |
有条件——bug 都在这 |
| 实体 | hashtags、urls、photos、videos、symbols、mentioned_users |
永远是数组,经常为空 |
| 作者 | author(嵌套对象) |
永远存在 |
真实的一条,删减后:
{
"id": "2118565802429632576",
"url": "https://x.com/elonmusk/status/2118565802429632576",
"conversation_id": "2118565802429632576",
"created_at": "Fri Nov 06 04:47:03 +0000 2026",
"text": "RT @AndrThie: This is a bigger deal than people...",
"lang": "en",
"source": "Twitter for iPhone",
"is_retweet": true,
"retweeted_tweet_id": "2118476679593312316",
"is_quote_status": true,
"quoted_tweet_id": "2118381463389307149",
"is_reply": false,
"in_reply_to_tweet_id": null,
"in_reply_to_user": null,
"in_reply_to_user_id": null,
"like_count": 0,
"retweet_count": 104,
"view_count": 217654,
"mentioned_users": ["AndrThie"],
"hashtags": [], "urls": [], "photos": [], "videos": [], "symbols": [],
"author": { "...": "..." }
}
盯着这个对象看一秒:它既是转推又是引用;like_count 是 0 而 retweet_count 是 104。两处都是对的,两处都会把朴素写法的解析器搞崩。原因下面说。
身份字段
id 是字符串,而且必须是
"id": "2118565802429632576"
19 位数字,超过了 JavaScript Number 能精确表示的范围——大于 2^53 的整数会静默丢精度。在 JS 里按整数解析,你拿到的是另一条推文的 ID,而且不报错。
// 错——静默损坏 ID
const id = Number(tweet.id); // 2118565802429632600 ← 看结尾
// 对——保持字符串
const id = tweet.id;
Python 原生支持大整数不会坏,但进 JSON、进 URL、进数据库列的时候,统一用字符串仍然更省事。
conversation_id 指向话题根
原创推文的 conversation_id 等于它自己的 id;串里任何一条都指向该串第一条。这是不用逐级往上爬回复链就能把整段对话归组的办法,配合高级搜索语法可以一次查出整串。
created_at 用的是推特自己的格式
"created_at": "Fri Nov 06 04:47:03 +0000 2026"
不是 ISO 8601,是老的 EEE MMM dd HH:mm:ss Z yyyy,永远 UTC。
from datetime import datetime
dt = datetime.strptime(tweet["created_at"], "%a %b %d %H:%M:%S %z %Y")
// JS 的 Date 不用库就能正确解析
const dt = new Date(tweet.created_at);
三个关系标志,以及它们为什么不互斥
这部分造成的解析 bug 最多,所以单开一节。
三个布尔标志,各自带一个 ID 搭档:
| 标志 | 搭档字段 | 含义 |
|---|---|---|
is_reply |
in_reply_to_tweet_id、in_reply_to_user、in_reply_to_user_id |
这条是回复 |
is_retweet |
retweeted_tweet_id |
这条是转推 |
is_quote_status |
quoted_tweet_id |
这条引用了别的推文 |
它们不互斥。 上面那条同时是转推和引用——有人转发了一条本身是引用推的推文。回复也可以同时是引用。理论上三个可以同时为真。
# 错——假设只能属于一类
if tweet["is_retweet"]:
kind = "retweet"
elif tweet["is_quote_status"]:
kind = "quote"
elif tweet["is_reply"]:
kind = "reply"
else:
kind = "original"
这个 elif 链会把样本判成单纯的转推,完全丢掉引用关系。如果你在做话题视图或回复图谱,那就是少了一条边。
# 对——它们是互相独立的事实
kinds = set()
if tweet["is_retweet"]: kinds.add("retweet")
if tweet["is_quote_status"]: kinds.add("quote")
if tweet["is_reply"]: kinds.add("reply")
if not kinds: kinds.add("original")
标志为 false 时搭档字段是 null。 读 ID 之前先判标志,或者用 .get() 兜底:
parent = tweet.get("in_reply_to_tweet_id") # 不是回复时为 None
我们所有端点对这几个标志的归一化规则完全一致,这正是结构化响应的意义——分支只写一次,不用每个端点写一遍。写代码之前可以先拿一条真实响应对一下形状。
计数:转推为什么显示 0 个赞
"like_count": 0,
"retweet_count": 104,
"view_count": 217654
看着像错的,直到你知道规则:互动数据归属原推,不归属转推。 有人转发时,转推对象带自己的一套计数,而这些几乎都是 0——因为没人对转推本身互动,大家互动的是原推。
如果你按互动量排序又没过滤转推,排行榜顶部会是一堆 0 和真实数字混在一起。
# 只对原创排序
originals = [t for t in tweets if not t["is_retweet"]]
originals.sort(key=lambda t: t["like_count"] + t["retweet_count"], reverse=True)
还有两条值得知道:
view_count是唯一会长期持续增长的计数。 赞和转推几小时内就趋平,浏览量不会。如果你定期快照互动数据,浏览量要单独存,否则会得出"这条几天后还在爆"的错误结论。bookmark_count在大推文上也经常是 0。 书签是私密的,计数不一定暴露。把 0 当"未知",不是"没人收藏"。
如果你要一次拉很多条推文的互动数据,按 ID 一条条取是最贵的做法——把请求批量化能让这笔账完全不同。
实体数组:永远是数组,通常是空的
"hashtags": [], "urls": [], "photos": [], "videos": [],
"symbols": [], "mentioned_users": ["AndrThie"]
六个实体字段。好用的性质是它们永远是数组——不会是 null,不会是裸对象。可以直接遍历,不用判空:
for tag in tweet["hashtags"]: # 空数组也安全
...
symbols 装的是股票代码标签($TSLA)。mentioned_users 装用户名,不带 @。photos 和 videos 是两个独立数组,图文混合的推文两个都会有值。
关于 text 和 urls 有个坑: text 里是缩短后的 t.co 链接,不是目标地址。要真实 URL 请读 urls 数组,不要去正则匹配正文。
嵌套的 author 对象
每条推文都内联带着作者,省掉一次请求:
"author": {
"id": "44196397",
"username": "elonmusk",
"display_name": "Elon Musk",
"followers_count": 241452659,
"following_count": 1421,
"statuses_count": 111504,
"created_at": "Tue Jun 02 20:12:29 +0000 2009",
"location": "",
"verified": false,
"blue_verified": true,
"verified_type": ""
}
这里有三个常见错误:
username 和 display_name 不是一回事。 username 是handle(相对稳定,用在 URL 里);display_name 是显示名(随时可改、可以是 emoji、可以为空)。数据库主键用 id,别用这两个。
verified 和 blue_verified 是两回事。 前者是老的认证勾,后者是订阅勾。样本里 verified: false 而 blue_verified: true,两个都对。只判 verified 会告诉你马斯克没认证。
location 是自由文本,经常为空。 用户填什么就是什么,没有地理编码也没有校验,经常是段玩笑。不要拿它做地域功能。
null 与空值速查
| 字段 | 什么时候是 null / 空 | 安全默认值 |
|---|---|---|
in_reply_to_* |
is_reply 为 false |
None |
retweeted_tweet_id |
is_retweet 为 false |
None |
quoted_tweet_id |
is_quote_status 为 false |
None |
hashtags / urls / photos / videos / symbols |
没有该实体 | [](绝不是 null) |
author.location |
用户没填 | "" |
author.verified_type |
非组织/政府账号 | "" |
bookmark_count |
经常,大推文也一样 | 0 按"未知"处理 |
一个能处理以上全部情况的防御式解析器:
def parse(t):
return {
"id": t["id"], # 保持字符串
"created": datetime.strptime(t["created_at"],
"%a %b %d %H:%M:%S %z %Y"),
"text": t["text"],
"author_id": t["author"]["id"], # 用 id 做键, 不用 username
"is_rt": t["is_retweet"],
"is_quote": t["is_quote_status"],
"is_reply": t["is_reply"],
"parent": t.get("in_reply_to_tweet_id"),
"quoted": t.get("quoted_tweet_id"),
"likes": t["like_count"],
"views": t["view_count"],
"tags": t["hashtags"], # 永远是 list
"verified": t["author"]["verified"] or t["author"]["blue_verified"],
}
整个对象都覆盖到了,上面描述的任何形状都不会让它抛异常。
维护这么一层解析,正是自己造轮子最隐蔽的成本——字段形状会变,而不同的抓取路线付这笔税的方式各不相同。
如果你压根不想维护这一层:我们的 API 返回的就是这篇文章里的结构——一样的字段名、一样的 null 规则,上面那个解析器原样就能跑。用 Google 一键注册送 $0.20 额度,不用信用卡。
常见问题
推特的 JSON 响应是什么格式?
每条推文一个扁平对象,里面嵌一个 author 对象。大约三十个顶层字段,覆盖身份、内容、计数、关系标志和实体数组。推文本身外面除了响应包装没有别的嵌套层。
推文 ID 为什么是字符串不是数字? 因为它是 19 位的 snowflake ID,超出了 JavaScript 的安全整数范围(2^53)。按数字解析会静默损坏最后几位。所有地方都保持字符串。
推文 JSON 里的 conversation_id 是什么意思?
整个话题串第一条推文的 ID。原创推文它等于自己的 id。用它可以直接把一整段对话归组,不用逐级往上爬回复。
created_at 为什么不能按 ISO 8601 解析?
推特用的是自己的老格式 EEE MMM dd HH:mm:ss Z yyyy,永远 UTC。Python 需要显式写 strptime 格式串;JavaScript 的 new Date() 原生就能解析。
一条推文可以同时是转推和引用吗?
可以。is_retweet 和 is_quote_status 是互相独立的布尔值——转发一条引用推就会两个都为真。回复也可以同时是引用。千万别当成互斥的分类。
转推为什么显示 0 个赞? 互动数据算在原推上。转推对象有自己的一套计数,通常是 0。按互动量排序前先把转推过滤掉,否则你排的是真实数字和结构性 0 的混合。
verified 和 blue_verified 有什么区别?
verified 是老的认证勾,blue_verified 是付费订阅勾。两个独立布尔值,一个账号可以只有其中一个。只判 verified 会把现在大多数带勾账号判错。
username 和 display_name 有什么区别?
username 是 @handle,用在 URL 里,相对稳定。display_name 是显示名,可以随便改、可以带 emoji、可以为空。存储主键请用数字 id。
实体数组会是 null 吗?
不会。hashtags、urls、photos、videos、symbols、mentioned_users 永远是数组,没有该实体时是 []。可以直接遍历不判空。
怎么拿到真实 URL 而不是 t.co 短链?
读 urls 数组,不要读 text。正文里是缩短后的 t.co,数组里才是展开后的目标地址。
view_count 为什么比其它计数高那么多? 浏览量统计的是曝光不是动作,而且在赞和转推趋平之后还会继续累加。如果你按时间快照互动数据,浏览量要单独当一条序列处理。
bookmark_count 准确吗? 经常不暴露,所以大推文上也会读到 0。把 0 当"未知",不要当"零收藏"。
source 字段里是什么?
发推所用的客户端,纯字符串——"Twitter for iPhone"、"Twitter Web App" 或第三方应用名。用来过滤自动发帖很有用。
possibly_sensitive 是什么? 平台对可能敏感内容打的布尔标志。它是展示决策的提示,不是审核结论。
怎么判断一条是原创推文? 三个关系标志全为 false:不是回复、不是转推、不是引用。这是唯一可靠的判法——只查其中一个必然有遗漏。
author 对象每条推文都带吗? 是,每条都内联。不需要第二次请求去解析作者是谁,处理成千上万条推文时这点很关键。
symbols 数组里是什么?
股票代码标签——$TSLA、$BTC 之类。和装 # 标签的 hashtags 是分开的。
一个推文对象有多大? 取决于实体多少和 bio 长度,通常 1-3 KB 的 JSON。在估算批量响应体积或队列消息大小时值得知道。
这些字段名会一直稳定吗? 像我们这样做了归一化的 API,字段名的稳定性是契约的一部分。直接读 X 的内部端点没有这个保证——形状说变就变,这正是自建爬虫的主要维护成本。
推文被删之后字段会怎样? 你会直接取不到它,而不是拿到一个字段全是 null 的墓碑对象。请显式处理"找不到"这种情况,别指望收到残缺记录。
看到这种 JSON 结构需要 X 开发者账号吗? 用第三方只读 API 不需要,你用的是服务商的 key。开发者账号是官方 API 才需要的,而官方 API 你主要是为了写操作(发推、回复、关注)。
emoji 和非拉丁文字在 JSON 里怎么编码的?
text 字段就是普通 UTF-8。坑在 display_text_range 和实体索引上——它们按 UTF-16 码元计数,所以一个 BMP 之外的 emoji 算 2 不算 1。带 emoji 的推文用 Python 字符串下标切片会切错位置。
实体索引为什么和我的字符串切片对不上?
因为 X 按 UTF-16 码元算,对齐的是 JavaScript 的 String.length。Python 里用 text.encode('utf-16-le') 转换后再索引,或者干脆直接用实体自带的文本值,别自己切。
JSON 里有推文语言吗? 推文上带一个语言代码,但那是 X 自己的检测结果,在短文本或混合语言上错得够频繁,不配做无兜底的分支判断。
两条推可以有相同的 conversation_id 吗? 可以——这正是它的用途。一个话题串里每条回复都带着首条推的 conversation_id,所以按它分组就能还原整串。