入口数据并不保证只有一种形状

X 的会话结果把时间线指令放在 threaded_conversation_with_injections_v2.instructions 中。有效 entries 不一定出现在第一条指令,所以解析器先遍历 instructions,找到真正包含 entries 的对象。主帖通常是 TimelineTimelineItem,线程回复则可能位于 TimelineTimelineModule 的 items 中。

同一个 tweet 对象也可能被另一层 tweet 包裹。代码因此在读取用户、legacy 字段和 card 时都保留两个候选路径。这不是漂亮的抽象,却能把上游接口小幅变化限制在解析层,不让页面组件理解原始结构。

文本、链接和长文要分别处理

正文优先读取 note_tweet 的长文结果,缺失时才回退到 legacy.full_text。t.co 短链不能直接展示:解析器把文本中的链接与 entities 对照,找到对应的 expanded_url 后替换;无法匹配的短链会移除,避免把内部跳转地址留给用户。最后再把常见的 & 还原。

这套处理仍有明确限制。它不是通用 HTML 解码器,也不会猜测已经失效的短链目标;输入中出现异常组合 URL 时,正则仍可能需要更新。把失败表现定义为“保留可读正文而不是让整条解析崩溃”,比追求一次覆盖所有边界更实际。

视频 variants 必须按内容类型和码率筛选

一个视频的 variants 可能包含 HLS 播放列表和多个 MP4 文件。直接取数组第一项既不保证能下载,也不保证清晰度。当前实现先过滤 content_type === video/mp4,再按 bitrate 从高到低排序,选择最高码率的可下载版本。没有 MP4 时不伪造结果。

普通图片和视频位于 extended_entities.media;部分播放器卡片则把媒体引用藏在 card.legacy.binding_values 的 JSON 字符串里。解析器需要先取出组件数据中的 media ID,再回到 media_entities 找 variants。两条路径最终输出相同的 medias 数组,页面不必知道媒体来自普通附件还是卡片。

帖子 ID 不能当普通 Number

社交平台 ID 的位数可能超过 JavaScript 安全整数上限。如果先转换为 Number,再序列化或拼 URL,末位就可能悄悄变化。代码从 id_str 创建 BigInt,并在内部模型中保持精确值。跨 JSON 边界时则必须显式转换为字符串,因为原生 JSON 不支持 BigInt。

这个细节很难通过肉眼发现:页面能打开、绝大多数 ID 也看似正常,只有足够大的值才出错。验证时需要使用已知长 ID,比较输入字符串、解析模型和最终下载请求中的每一位,而不能只断言字段存在。

线程只收录作者自己的连续内容

TimelineTimelineModule 同时可能包含原作者续帖和其他人的回复。当前实现只接受 tweetDisplayType === SelfThread 的条目,避免把讨论区所有媒体混进一个下载结果。每条续帖独立解析文字和媒体,再按时间线顺序加入结果。

这也意味着解析器不会自动下载所有引用帖或回复中的附件。这个边界既减少版权和意外批量下载风险,也让“保存一个线程”保持可预测。工具只应用于用户有权保存的内容;解析到公开媒体地址不等于获得重新发布许可。

稳定输出比追随原始字段更重要

页面真正需要的是帖子 ID、可读文本和媒体列表。把上游结构压缩成这三个稳定概念后,接口变化只会影响解析器。解析失败时返回空数组并记录错误,调用方可以显示失败状态,而不是拿到一半结构继续运行。

下一步更值得补的是基于真实样本的固定测试:普通图片、单视频、多码率、卡片视频、长文和 SelfThread 各保留一份脱敏 fixture。这样上游字段变化会在部署前显现,而不是等用户发现下载按钮消失。