一条推文的 JSON 里到底有什么(逐字段拆解)

18 min readSocialAPI 工程团队

一条推文的 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,所以按它分组就能还原整串。