diff --git a/CHANGELOG.md b/CHANGELOG.md index dea4f182..868b3cdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,8 +72,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ #### X 前端迁移修复(2026-09-25) -- X 登出首页迁移到 Rolldown/Vite 后不再内嵌 `ondemand.s` webpack chunk map,`x-client-transaction-id` 因此抛 `OnDemandFileUrlResolutionError`,导致所有走 rettiwt-api 的 X 数据接口失败。`FetcherService` 改为在仍提供旧版 webpack 外壳的路由中探测取文档(`X_LEGACY_HOME_URLS` + `isUsableXDocument` 双条件校验),不再固定依赖 `/home` -- 新增 `test/unit/rettiwt-transaction-document.spec.ts`;沉淀 postmortem 013 +- X 迁移到 Rolldown/Vite 后不再内嵌 `ondemand.s` webpack chunk map,`x-client-transaction-id` 因此抛 `OnDemandFileUrlResolutionError`,所有走 rettiwt-api 的 X 数据接口失败。**真因是外壳请求漏带登录 cookie**(X 仅对登录态返回仍含该 chunk map 的旧外壳):新增 `buildXShellHeaders`,配置 API Key 时附带解码后的 cookie,`_fetchXHomePage` 改为使用它 +- 外壳候选列表改为 `X_SHELL_URLS`(`/home` 优先,legacy 路由作无 Key 时的 guest 兜底)+ `isUsableXDocument` 双条件校验;新增/扩充 `test/unit/rettiwt-transaction-document.spec.ts` +- 沉淀 postmortem 013 与 `docs/features/tweet/transaction-id.md`(含 `ondemand.s` indices 每构建随机、不可 pin 的实测) +- 补 AC-TWEET-011 回归防护(行为断言:mock `axios.get` 跑真实 `_fetchTransactionDocument`,锁定外壳请求必带 cookie、无 Key 时保持匿名),与 AC 文档 1:1 绑定 #### 验证诚信修复(2026-09-12) diff --git a/app/lib/rettiwt-api/services/public/FetcherService.ts b/app/lib/rettiwt-api/services/public/FetcherService.ts index adeb195c..49cac73c 100644 --- a/app/lib/rettiwt-api/services/public/FetcherService.ts +++ b/app/lib/rettiwt-api/services/public/FetcherService.ts @@ -24,24 +24,52 @@ import { ErrorService } from '../internal/ErrorService' import { LogService } from '../internal/LogService' /** - * X routes that still serve the legacy (webpack) client shell. + * Candidate URLs for the X shell document that drives `ClientTransaction`. * - * X is migrating its frontend to a Rolldown/Vite build ("x-web") that no longer - * embeds the `ondemand.s` webpack chunk map required by `x-client-transaction-id` - * to derive the key byte indices. The landing pages (`/`, `/home`) already serve - * the new build, but the routes below still return the legacy shell, so we probe - * them in order until one exposes the runtime the library needs. + * The `ondemand.s` webpack chunk map required by `x-client-transaction-id` only + * remains in the legacy shell, and X serves that legacy shell only to an + * authenticated session (see `buildXShellHeaders`). The canonical homepage comes + * first; the remaining routes still return the legacy shell anonymously, as a + * fallback for deployments without an API key. * * @internal */ -export const X_LEGACY_HOME_URLS = [ +export const X_SHELL_URLS = [ + 'https://x.com/home', 'https://x.com/i/topics', 'https://x.com/i/display', 'https://x.com/i/timeline', 'https://x.com/i/communitynotes', - 'https://x.com/home', ] +/** + * Builds the headers used to fetch the X shell document. + * + * X only serves the shell containing the `ondemand.s` chunk map to an + * authenticated session, so the decoded cookie must be attached whenever an API + * key is configured. An anonymous request returns the new Rolldown/Vite build + * (which dropped the chunk map), making `ClientTransaction` uninitializable. + * + * @param headers - The base request headers. + * @param apiKey - The API key whose cookies are to be attached, if any. + * + * @returns A copy of the given headers, with the cookie attached when applicable. + * + * @internal + */ +export function buildXShellHeaders( + headers: { [key: string]: string }, + apiKey?: string, +): { [key: string]: string } { + const shellHeaders = { ...headers } + + if (apiKey) { + shellHeaders.cookie = AuthService.decodeCookie(apiKey) + } + + return shellHeaders +} + /** * Whether the given document is a usable legacy X shell for client transaction * ID generation: it must expose both the site verification key and the @@ -162,11 +190,11 @@ export class FetcherService { /** * Resolves a DOM document that can drive `ClientTransaction` initialization. * - * X's new Rolldown/Vite shell dropped the `ondemand.s` webpack chunk map, so - * the previous fixed `/home` fetch fails with `OnDemandFileUrlResolutionError`. - * We probe the legacy routes in `X_LEGACY_HOME_URLS` and return the first - * usable shell, falling back to the last parsed document so the library can - * raise its own specific initialization error. + * X's new Rolldown/Vite shell dropped the `ondemand.s` webpack chunk map, which + * is why the shell must be fetched with the session cookie (see + * `buildXShellHeaders`). We probe the candidates in `X_SHELL_URLS` and return the + * first usable shell, falling back to the last parsed document so the library + * can raise its own specific initialization error. * * @returns The parsed X document. */ @@ -174,7 +202,7 @@ export class FetcherService { let lastDocument: Document | undefined let lastError: unknown - for (const url of X_LEGACY_HOME_URLS) { + for (const url of X_SHELL_URLS) { try { const document = await this._fetchXHomePage(url) @@ -207,9 +235,9 @@ export class FetcherService { * @returns The parsed X document. */ private async _fetchXHomePage(url: string): Promise { - // Fetch the X page shell + // Fetch the X page shell (with the session cookie, when available) const homePageResponse = await axios.get(url, { - headers: this.config.headers, + headers: buildXShellHeaders(this.config.headers, this.config.apiKey), httpAgent: this.config.httpsAgent, httpsAgent: this.config.httpsAgent, }) diff --git a/docs/development-log/2026-09-25.md b/docs/development-log/2026-09-25.md index 1e8397a4..d728fff5 100644 --- a/docs/development-log/2026-09-25.md +++ b/docs/development-log/2026-09-25.md @@ -45,3 +45,48 @@ **文档**:[postmortem 013](../postmortem/013-upstream-frontend-drift.md) +「高频雷区」第 11 条 + `CHANGELOG.md`。 + +## 真正根因:外壳请求缺 cookie(✅ 已完成,同日追加) + +**背景**:owner 质疑「X 的重构是逐步的,`/i/topics` 只是还没轮到,库依赖的东西才是核心」—— +顺着这个质疑复测,发现根因不在「换哪个页面」。 + +**决定性实测**(同一 URL `x.com/home`,仅切换 `Cookie`): + +| 请求 | 外壳 | `ondemand.s` | +| ----------------- | -------------- | ------------ | +| 匿名 `/home` | 16.8 KB 新外壳 | ❌ | +| 带 cookie `/home` | 305 KB 旧外壳 | ✅ | + +- 上游/此前的 `_handleXMigration()` 只发 `config.headers`,cookie 只在 `request()` 组装 API 请求时 + 注入 → **外壳请求永远匿名**,即便配置了 API Key。 +- 这是**回归**:Rettiwt #885 曾以「抓外壳时附 cookie」修好同一错误,被 #888 合并时丢失; + 上游 Lqm1 issue #20 亦归因为「需要有效登录 session」。 +- 端到端:`TweetDetail` 与 `TweetLikers`(#908 的复现接口)均成功返回。 + +**实现**:新增纯函数 `buildXShellHeaders(headers, apiKey)`(存在 key 时附 +`AuthService.decodeCookie(apiKey)`),`_fetchXHomePage` 改为使用它;候选列表改名 `X_SHELL_URLS` +并把 `/home` 提到首位(有 cookie 时首个即命中,legacy 路由降级为无 Key 时的 guest 兜底)。 + +**另**:实测 `ondemand.s` 的 key byte indices **每次构建随机**(30 个日更版本 30/30 不同), +故只可缓存 URL(内容哈希、不可变),不可 pin / 缓存 indices。 + +**验证**:`test/unit/rettiwt-transaction-document.spec.ts` 扩到 8 用例(含 cookie 头三项断言); +真实上游 E2E 复核通过。详见 [postmortem 013](../postmortem/013-upstream-frontend-drift.md) +「后续修正」与 [transaction-id.md](../features/tweet/transaction-id.md)。 + +## AC 收口:外壳 cookie 回归防护(AC-TWEET-011,✅ 已完成) + +**动机**:纯函数 `buildXShellHeaders` 的单测只能证明「这个函数对」,**发现不了「调用点不再用它」**—— +而这正是本次 bug 的形态(上游 #885 修好、#888 合并时丢失的回归)。所以补一条**行为断言**: +mock `axios.get` 后跑真实 `_fetchTransactionDocument`,断言**实际发出的**外壳请求头 `cookie` +等于解码后的 cookie;无 `apiKey` 时保持匿名。 + +**落位**:`test/acceptance/ac-tweet.spec.ts`(`describe('AC-TWEET-011: …')`),与 +`verify/acceptance-criteria/AC-tweet.md` v1.2 的 AC-TWEET-011 按 ac-contract 契约 1:1 绑定。 + +**Falsifiability 复核**:临时把 `_fetchXHomePage` 回退为 `this.config.headers` 后,该用例报 +`expected undefined to be 'auth_token=…'`——确认它真能捕获本类回归,而非恒真(对齐 postmortem 011 +「门禁先问『改坏它会不会红』」)。 + +**范围**:仅补验证与文档,运行时语义不变——PR #21 的 cookie 修复即最终实现。 diff --git a/docs/development-log/README.md b/docs/development-log/README.md index 406bafc9..2f09d58a 100644 --- a/docs/development-log/README.md +++ b/docs/development-log/README.md @@ -4,21 +4,21 @@ ## 时间线(新 → 旧) -| 日期 | 主题 | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [2026-09-25](2026-09-25.md) | 修复 X 前端迁移(webpack→Rolldown/Vite)击穿 client transaction id 导致 get tweet 全站失败:改从仍提供旧外壳的路由探测取文档(`X_LEGACY_HOME_URLS`)+ `isUsableXDocument` 双条件校验;沉淀 postmortem 013 | -| [2026-09-16](2026-09-16.md) | X Space 卡片接入(audiospace 空壳卡片 → `AudioSpaceById` 补全标题/主播/人数/时长;范围裁定「卡片 + 跳转 X 播放」;AC-SPACE-001~008;不改高危解析器) | -| [2026-09-12](2026-09-12.md) | 验证诚信修复落地(F1 dev server `NODE_ENV` 泄漏 / F2 零匹配守卫 + 模块别名 / F3 死断言 / F4 AC↔测试名元测试 / F5 workflow 步骤解析 / F8 去 fixture 自证 / F9 AC-TRANS 命名 / F10 断言 lint / F12 README / F13 deprecated 参数)+ postmortem 011 | -| [2026-09-11](2026-09-11.md) | anon-tweet Agent Skill 升级 v1.2.0(随附 PowerShell 封装 + OpenAPI 快照;随附 spec 与 llms.ts 14/14 奇偶一致)+ 版本单源治理(应用版本归 package.json / skill 版本归 SKILL.md,去手写副本)+ 验证套件诚信审查(AC 空跑报绿 / 静态扫描冒充行为断言;修复 backlog F1~F13) | -| [2026-09-06](2026-09-06.md) | 可安装 PWA + Web Share Target(系统分享直达推文/IG;极简网络透传 SW + 首页 GET 接收复用 TweetInputForm) | -| [2026-09-04](2026-09-04.md) | 媒体图片链接统一为 `/slug.jpg`(去掉 `?name=&format=` 查询参数,normalizeMediaUrl 收口) | -| [2026-08-31](2026-08-31.md) | 推文搜索接入(/search 页面 + /api/tweet/search + rettiwt-api guest 授权补缺)+ llms.txt / openapi.json 接入 + tweet GET 全部入口统一走缓存链(getLocalTweet) | -| [2026-08-19](2026-08-19.md) | 主线推送 34 提交 + baseUrl 白名单改可选加固(默认关闭) | -| [2026-08-18](2026-08-18.md) | 修复 unified_card 解析只认 `details_1` 布局(Trending/topic 卡片丢失 og 信息) | -| [2026-08-17](2026-08-17.md) | 修复「句首补充」显示与持久化链路(index 对齐合并丢实体 + AI 流丢片段 + 保存即同步) | -| [2026-08-14](2026-08-14.md) | 测试基线红灯修复(vitest 冷加载超时,独立任务) | -| [2026-08-13](2026-08-13.md) | AI 视觉描述子系统规划(OpenRouter + MiMo-V2.5,文档先行 Phase 0) | -| [2026-08-09](2026-08-09.md) | 工程规范对标 Float + verify 二期 S5/S6/S8(CI / Screenshot Verifier / --server 自动管理) | +| 日期 | 主题 | +| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [2026-09-25](2026-09-25.md) | 修复 X 前端迁移(webpack→Rolldown/Vite)击穿 client transaction id 导致 get tweet 全站失败:改从仍提供旧外壳的路由探测取文档 → **真因 = 外壳请求漏带 cookie**(`buildXShellHeaders`,仅登录态返回含 `ondemand.s` 的旧外壳)+ `X_SHELL_URLS` 候选校验作 guest 兜底;沉淀 postmortem 013 + AC-TWEET-011 外壳 cookie 回归防护 | +| [2026-09-16](2026-09-16.md) | X Space 卡片接入(audiospace 空壳卡片 → `AudioSpaceById` 补全标题/主播/人数/时长;范围裁定「卡片 + 跳转 X 播放」;AC-SPACE-001~008;不改高危解析器) | +| [2026-09-12](2026-09-12.md) | 验证诚信修复落地(F1 dev server `NODE_ENV` 泄漏 / F2 零匹配守卫 + 模块别名 / F3 死断言 / F4 AC↔测试名元测试 / F5 workflow 步骤解析 / F8 去 fixture 自证 / F9 AC-TRANS 命名 / F10 断言 lint / F12 README / F13 deprecated 参数)+ postmortem 011 | +| [2026-09-11](2026-09-11.md) | anon-tweet Agent Skill 升级 v1.2.0(随附 PowerShell 封装 + OpenAPI 快照;随附 spec 与 llms.ts 14/14 奇偶一致)+ 版本单源治理(应用版本归 package.json / skill 版本归 SKILL.md,去手写副本)+ 验证套件诚信审查(AC 空跑报绿 / 静态扫描冒充行为断言;修复 backlog F1~F13) | +| [2026-09-06](2026-09-06.md) | 可安装 PWA + Web Share Target(系统分享直达推文/IG;极简网络透传 SW + 首页 GET 接收复用 TweetInputForm) | +| [2026-09-04](2026-09-04.md) | 媒体图片链接统一为 `/slug.jpg`(去掉 `?name=&format=` 查询参数,normalizeMediaUrl 收口) | +| [2026-08-31](2026-08-31.md) | 推文搜索接入(/search 页面 + /api/tweet/search + rettiwt-api guest 授权补缺)+ llms.txt / openapi.json 接入 + tweet GET 全部入口统一走缓存链(getLocalTweet) | +| [2026-08-19](2026-08-19.md) | 主线推送 34 提交 + baseUrl 白名单改可选加固(默认关闭) | +| [2026-08-18](2026-08-18.md) | 修复 unified_card 解析只认 `details_1` 布局(Trending/topic 卡片丢失 og 信息) | +| [2026-08-17](2026-08-17.md) | 修复「句首补充」显示与持久化链路(index 对齐合并丢实体 + AI 流丢片段 + 保存即同步) | +| [2026-08-14](2026-08-14.md) | 测试基线红灯修复(vitest 冷加载超时,独立任务) | +| [2026-08-13](2026-08-13.md) | AI 视觉描述子系统规划(OpenRouter + MiMo-V2.5,文档先行 Phase 0) | +| [2026-08-09](2026-08-09.md) | 工程规范对标 Float + verify 二期 S5/S6/S8(CI / Screenshot Verifier / --server 自动管理) | ## 记录约定 diff --git a/docs/features/tweet/transaction-id.md b/docs/features/tweet/transaction-id.md new file mode 100644 index 00000000..671a3d8a --- /dev/null +++ b/docs/features/tweet/transaction-id.md @@ -0,0 +1,105 @@ +# X client transaction ID:前端迁移(webpack → Rolldown/Vite)的根因与应对 + +> 状态:调研完成,结论已实测 | 日期:2026-09-25 +> 关联:[postmortem 013](../../postmortem/013-upstream-frontend-drift.md) · Rettiwt-API #908 · Lqm1/x-client-transaction-id #17/#20 · tweety #298 + +## 0. 结论先行 + +- **根因不在「换哪个页面」,而在「外壳请求没带登录 cookie」。** + 同一 URL `x.com/home`:匿名 → 新外壳(无 `ondemand.s`);**带 cookie → 旧外壳(有 `ondemand.s`)**。 +- 上游 `_handleXMigration()` 只发 `config.headers`,**从不发 cookie**,所以即使用户配了 API Key, + 外壳请求仍是匿名的 → 必然拿到新外壳 → 抛 `OnDemandFileUrlResolutionError`。 +- 这是**回归**:Rettiwt #885 曾用「带上解密 cookie」修好过,被 #888 合并时回退掉了。 +- 我们的项目 `TWEET_KEYS` 是必配的(生产无 guest 场景),所以修好 = 外壳请求带 cookie,一行级别的改动。 + +## 1. 机制拆解:库需要什么 + +| 输入 | 来源 | 旧外壳 | 新外壳 | +| --------------------- | ------------------------------------------ | ------ | ------ | +| site verification key | `` | ✅ | ✅ | +| anim frames | `#loading-x-anim-*` 的 SVG path | ✅ | ✅ | +| **key byte indices** | `ondemand.s` chunk 内的 `(\w[n], 16)` 正则 | ✅ | ❌ | + +只有 indices 绑定 webpack chunk map;另两项输入新外壳也提供。所以问题收敛为「如何拿到旧外壳」。 + +## 2. 关键证据 + +### 2.1 决定性:登录态决定拿到哪个外壳(本机实测) + +同一 URL,仅切换 `Cookie` 头(cookie 来自本机 `.env` 的 `TWEET_KEYS`,`Buffer.from(key,'base64')`): + +| 模式 | URL | 响应大小 | `ondemand.s` | meta | anim frames | +| --------- | ----------- | -------- | ------------ | ---- | ----------- | +| 匿名 | `/home` | 16.8 KB | **0** | 1 | 4 | +| 带 cookie | `/home` | 305 KB | **1** | 1 | 4 | +| 带 cookie | `/` | 305 KB | **1** | 1 | 4 | +| 带 cookie | `/i/topics` | 305 KB | **1** | 1 | 4 | + +**端到端**:用 cookie 版 `/home` 文档喂给真实 `FetcherService`(真实 `apiKey`), +`TWEET_DETAILS`(guest 允许)与 **`TWEET_LIKERS`(仅登录可用,即 #908 的复现接口)均成功返回**。 +`ClientTransaction.create()` 初始化正常、生成 94 位 transaction id。 + +### 2.2 上游缺陷所在 + +`_handleXMigration()`(`src/services/public/FetcherService.ts`)只用 `this.config.headers` 取外壳, +cookie 只在 `request()` 组装 API 请求时才通过 `cred.toHeader()` 注入 → 外壳请求永远匿名。 +#885 正是加了 `migrationHeaders['cookie'] = AuthService.decodeCookie(apiKey)` 修好的;#888 合并后丢失。 +上游 Lqm1 侧亦把同一现象归因为「需要有效登录 session」(issue #20)。 + +### 2.3 indices 每次构建都变,不能 pin / 长期缓存 + +取 fa0311 维护的 legacy chunk map 历史,逐个下载当日 `ondemand.s` 并用库自带正则提取 —— **30/30 全部不同**: + +| 日期 | indices | 日期 | indices | +| ----- | ----------- | ----- | ----------- | +| 09-24 | 19,19,21,4 | 09-09 | 1,28,3,46 | +| 09-22 | 20,37,47,5 | 09-05 | 20,45,22,32 | +| 09-19 | 47,41,13,12 | 09-01 | 11,2,33,34 | +| 09-16 | 18,15,11,13 | 08-20 | 2,1,1,30 | +| 09-12 | 9,10,21,24 | 08-14 | 42,2,4,4 | + +⇒ **可缓存 URL(内容哈希、不可变),不可缓存 indices**(会算出无效 id)。必须运行时解析当前 chunk。 + +### 2.4 「换页面」只是时间赛跑(非本方案依赖) + +匿名下 `/home`、`/`、`/explore`、`/i/grok` 恒为新外壳;`/i/topics`、`/i/display`、`/i/timeline`、 +`/i/communitynotes`、`/jobs` 恒为旧外壳 —— 仅因尚未迁移。带 cookie 后所有页面都是旧外壳,无需依赖这些。 + +### 2.5 上游库现状 + +Lqm1:issue #17/#20 同一错误;历史修复为「改正则」(PR #18/#19) + 「带登录 session」(Rettiwt #885 的思路) + +- 文档改指 `/home`(PR #21)。`main` 此后未改,npm 最新 `0.3.1` 即当前实现。 + +## 3. 方案与推荐 + +| 方案 | 做法 | 评价 | +| ------------------------------- | --------------------------------------------------------- | --------------------------------------------------------- | +| **B 外壳请求带 cookie**(推荐) | `_handleXMigration` 附 `AuthService.decodeCookie(apiKey)` | 命中我们「cookie 必配」的场景;上游已有先例;真正修复根因 | +| A 换页面 URL | `/home` → `/i/topics` | 仅绕过;不解决根因,且匿名下仍是时间赛跑 | +| C 多页面探测 + 校验 | 候选列表 + 校验 | 匿名 fallback 才有意义 | +| E 缓存 URL(短 TTL) | 只缓存 ondemand URL | 降载,可叠加 | +| F 上游库可插拔 | `create(doc, { onDemandFileUrl })` / 多页面 / 回退 | 长期方向,需 Lqm1 合并 | +| G 跟踪新栈 | `x-tfe-transaction-id` 等新头 | 战略观察 | + +**推荐**:B 为主(本项目 + 上游 PR),E 可选叠加。guest(无 key)残留问题单列(见 §4)。 + +## 4. 残留问题 / 后续 + +1. **guest 模式**(无 `apiKey`,如自部署未配 `TWEET_KEYS`)仍然匿名 → 仍会失败。上游支持 guest, + 若要覆盖,需要「候选页面探测」兜底(方案 C)或上游可插拔(F)。我们项目不涉及。 +2. 向上游提 PR:以「#885 回归修复」的定位提交 B(最小、有先例、易被接受)。 +3. 监控:加每日探针(匿名/登录外壳是否含 `ondemand.s`),失效告警。 +4. 是否 X 会把需要 transaction id 的 legacy GraphQL 端点也迁到新栈?若迁,机制整体改变。 + +## 5. 复现方式 + +```bash +# cookie 来自 TWEET_KEYS[0](base64) +KEY=$(grep -E '^\s*TWEET_KEYS\s*=' .env | cut -d= -f2- | cut -d, -f1 | tr -d ' ') +COOKIE=$(node -e "process.stdout.write(Buffer.from(process.argv[1],'base64').toString())" "$KEY") + +UA='Mozilla/5.0 (X11; Linux x86_64; rv:144.0) Gecko/20100101 Firefox/144.0' +curl -sL --compressed -A "$UA" https://x.com/home | grep -c ondemand.s # 0 = 新外壳 +curl -sL --compressed -A "$UA" -H "Cookie: $COOKIE" https://x.com/home | grep -c ondemand.s # 1 = 旧外壳 +``` diff --git a/docs/postmortem/013-upstream-frontend-drift.md b/docs/postmortem/013-upstream-frontend-drift.md index 672a6ee9..a8f99e48 100644 --- a/docs/postmortem/013-upstream-frontend-drift.md +++ b/docs/postmortem/013-upstream-frontend-drift.md @@ -58,9 +58,9 @@ X 把登出首页从 webpack 迁到 **Rolldown/Vite**(新 "x-web"):`/` 与 ### 缓解(针对已发生的具体缺口) -- [x] `_handleXMigration` → `_fetchTransactionDocument`:遍历 `X_LEGACY_HOME_URLS` - (`/i/topics` → `/i/display` → `/i/timeline` → `/i/communitynotes` → `/home`), - 返回第一个 `isUsableXDocument` 为真的文档;无命中则回退最后一个文档,让库抛出更具体的错误(2026-09-25) +- [x] `_handleXMigration` → `_fetchTransactionDocument`:遍历候选外壳 `X_SHELL_URLS` + (`/home` 优先,`/i/topics` 等为 guest 兜底),返回第一个 `isUsableXDocument` 为真的文档; + 无命中则回退最后一个文档,让库抛出更具体的错误(2026-09-25) - [x] 新增 `isUsableXDocument(document)`(`twitter-site-verification` meta + `ondemand.s` 双条件)(2026-09-25) - [x] 新增错误 `ApiErrors.HOMEPAGE_FETCH_FAILED`(所有候选均网络失败时使用)(2026-09-25) - [x] 单测 `test/unit/rettiwt-transaction-document.spec.ts`(旧/新外壳判定 + 列表非空)(2026-09-25) @@ -71,12 +71,33 @@ X 把登出首页从 webpack 迁到 **Rolldown/Vite**(新 "x-web"):`/` 与 或加一层「外壳健康探针」定时校验并在异常时告警),避免再次全站硬失败 - [ ] 评估将 transaction 文档做**短 TTL 缓存**,顺带降低每请求 300KB 首页拉取的开销 +## 后续修正(2026-09-25):真正根因是外壳请求缺 cookie + +初次修复(探测候选页面)之后进一步实测,定位到**真正根因**并修正: + +- 同一 URL `x.com/home`:**匿名** → 新 Rolldown/Vite 外壳(16.8 KB,无 `ondemand.s`); + **带 cookie** → 旧外壳(305 KB,有 `ondemand.s`)。 +- 上游 `_handleXMigration()` 只发 `config.headers`,cookie 只在 `request()` 组装 API 请求时注入 + → **外壳请求永远匿名**,即便配置了 API Key。 +- 这是**回归**:Rettiwt #885 曾以「抓外壳时附 cookie」修好同一错误,被 #888 合并时丢失; + 上游 Lqm1 issue #20 亦归因为「需要有效登录 session」。 + +修法:`buildXShellHeaders(headers, apiKey)` 在存在 API Key 时附带 +`AuthService.decodeCookie(apiKey)`;候选页面列表(`X_SHELL_URLS`,`/home` 优先)降级为 +**无 Key(guest)时的兜底**。实测:真实 `TweetDetail` 与 `TweetLikers`(#908 复现接口)均成功。 + +另:实测 `ondemand.s` 的 key byte indices **每次构建随机**(30 个日更版本 30/30 不同), +故只能缓存 **URL**(内容哈希、不可变),不能 pin / 缓存 indices。 + ## 教训 +- **别只盯「上游库坏了」,先查「我们发出去的请求对不对」**:本次真因是我们自己的请求缺 cookie + (且是历史修复被合并丢失的回归),换页面只是绕开症状。诊断顺序应为「我们的请求 → 上游库 → 上游站点」。 +- 「同一 URL 因身份不同返回不同内容」的登录态依赖,必须**同时验证匿名与登录两条路径**。 - **依赖上游页面/接口结构的功能,必须多候选 + 可用性校验 + 明确降级**,不能把某个 URL 当成稳定单点;「上游库最新版」不等于「上游已适配」。 - 上游格式漂移是**运行时**风险,typecheck/lint/test 覆盖不到——关键外部依赖需要有 - 「真实调用冒烟」类验证(本次用真实 guest 请求兜底)。 + 「真实调用冒烟」类验证(本次用真实登录请求兜底)。 ## Changed Files @@ -84,6 +105,7 @@ X 把登出首页从 webpack 迁到 **Rolldown/Vite**(新 "x-web"):`/` 与 app/lib/rettiwt-api/services/public/FetcherService.ts app/lib/rettiwt-api/enums/Api.ts test/unit/rettiwt-transaction-document.spec.ts +docs/features/tweet/transaction-id.md docs/postmortem/013-upstream-frontend-drift.md docs/postmortem/README.md docs/development-log/2026-09-25.md diff --git a/test/acceptance/ac-tweet.spec.ts b/test/acceptance/ac-tweet.spec.ts index d883edcb..f744e1f5 100644 --- a/test/acceptance/ac-tweet.spec.ts +++ b/test/acceptance/ac-tweet.spec.ts @@ -1,3 +1,4 @@ +import type { AxiosResponse } from 'axios' import type { ITweetSearchResponse } from '~/lib/rettiwt-api/types/raw/tweet/Search' import type { EnrichedTweet, Entity } from '~/types' /** @@ -7,10 +8,14 @@ import type { EnrichedTweet, Entity } from '~/types' * - AC-TWEET-001~004 / 007:**fixture 作输入 → 调真实纯函数 → 断言产出**(F8 去「fixture 自证」, * review-2026-09-11 P2-1:原实现直接断言 fixture JSON 自身字段,解析回归不会红) * - AC-TWEET-009:真实 `parseSearchTimeline` + * - AC-TWEET-011:mock `axios.get` 后跑真实 `_fetchTransactionDocument`,断言外壳请求带 cookie * - AC-TWEET-005/006/008/010 为集成测试,见 test/integration/api.tweet.spec.ts */ -import { describe, expect, it } from 'vitest' +import axios from 'axios' +import { describe, expect, it, vi } from 'vitest' import { parseSearchTimeline } from '~/lib/react-tweet/utils/get-tweet' +import { RettiwtConfig } from '~/lib/rettiwt-api/models/RettiwtConfig' +import { FetcherService } from '~/lib/rettiwt-api/services/public/FetcherService' import { stripTranslationsFromTweets } from '~/lib/stores/logic' import { mergeEntityTranslationsByIndex } from '~/lib/translation/resolveEntities' import { loadFixture } from '../helpers/load-fixture' @@ -115,3 +120,50 @@ describe('AC-TWEET tweet parsing (fixture → real pipeline)', () => { expect(nextCursor).toBe('dGhlX2JvdHRvbV9jdXJzb3Jfb2Zfc2VhcmNo') }) }) + +/** + * AC-TWEET-011 回归防护:外壳请求必须带 cookie。 + * + * 上游 #885 曾以「抓外壳时附 cookie」修好 `OnDemandFileUrlResolutionError`,被 #888 合并时丢失; + * 纯函数 `buildXShellHeaders` 的单测无法发现「调用点不再使用它」这一类回归,故这里 mock + * `axios.get` 后跑真实 `_fetchTransactionDocument`,断言**实际发出的请求头**。 + */ +const SHELL_COOKIES = 'auth_token=abc;ct0=def;twid=u%3D12345;' +const LEGACY_SHELL = [ + '', + '', + '', + '', +].join('') + +interface ShellProbe { _fetchTransactionDocument: () => Promise } +interface ShellRequest { headers: Record } + +function mockShellGet() { + return vi.spyOn(axios, 'get').mockResolvedValue({ data: LEGACY_SHELL } as unknown as AxiosResponse) +} + +describe('AC-TWEET-011: X shell request carries the session cookie', () => { + it('attaches the decoded cookie when an API key is configured', async () => { + const apiKey = Buffer.from(SHELL_COOKIES).toString('base64') + const service = new FetcherService(new RettiwtConfig({ apiKey })) + const get = mockShellGet() + + await (service as unknown as ShellProbe)._fetchTransactionDocument() + + expect(get).toHaveBeenCalledTimes(1) + const [url, config] = get.mock.calls[0]! + expect(url).toBe('https://x.com/home') + expect((config as unknown as ShellRequest).headers.cookie).toBe(SHELL_COOKIES) + }) + + it('stays anonymous when no API key is configured', async () => { + const service = new FetcherService(new RettiwtConfig({})) + const get = mockShellGet() + + await (service as unknown as ShellProbe)._fetchTransactionDocument() + + const [, config] = get.mock.calls[0]! + expect((config as unknown as ShellRequest).headers).not.toHaveProperty('cookie') + }) +}) diff --git a/test/unit/rettiwt-transaction-document.spec.ts b/test/unit/rettiwt-transaction-document.spec.ts index 5ec60017..f602d8e9 100644 --- a/test/unit/rettiwt-transaction-document.spec.ts +++ b/test/unit/rettiwt-transaction-document.spec.ts @@ -1,14 +1,14 @@ import { parseHTML } from 'linkedom' import { describe, expect, it } from 'vitest' -import { isUsableXDocument, X_LEGACY_HOME_URLS } from '~/lib/rettiwt-api/services/public/FetcherService' +import { buildXShellHeaders, isUsableXDocument, X_SHELL_URLS } from '~/lib/rettiwt-api/services/public/FetcherService' /** * test/unit/rettiwt-transaction-document.spec.ts * - * X 迁移到 Rolldown/Vite 后,`/` ` /home` 不再内嵌 `ondemand.s` webpack chunk map, - * 导致 x-client-transaction-id 抛 OnDemandFileUrlResolutionError。FetcherService - * 改为在 X_LEGACY_HOME_URLS 中探测仍提供旧版外壳的页面。这里固化「可用文档」判定: - * 必须同时具备 site verification meta 与 ondemand.s chunk 引用。 + * X 只对**登录态**返回仍内嵌 `ondemand.s` webpack chunk map 的旧版外壳,匿名请求拿到 + * 新的 Rolldown/Vite 外壳 → x-client-transaction-id 抛 OnDemandFileUrlResolutionError。 + * 这里固化两处纯逻辑:外壳请求头必须附带登录 cookie(buildXShellHeaders),以及候选 + * 外壳的可用性判定(isUsableXDocument:需同时具备 site verification meta 与 ondemand.s)。 */ function doc(body: string): Document { @@ -17,6 +17,29 @@ function doc(body: string): Document { const VERIFICATION_META = '' +describe('buildXShellHeaders', () => { + it('attaches the decoded cookie when an API key is present', () => { + const cookies = 'auth_token=abc;ct0=def;twid=u%3D1;' + const apiKey = Buffer.from(cookies).toString('base64') + + const headers = buildXShellHeaders({ 'User-Agent': 'ua' }, apiKey) + + expect(headers.cookie).toBe(cookies) + expect(headers['User-Agent']).toBe('ua') + }) + + it('does not attach a cookie without an API key', () => { + expect(buildXShellHeaders({ 'User-Agent': 'ua' })).not.toHaveProperty('cookie') + }) + + it('does not mutate the given headers', () => { + const base = { 'User-Agent': 'ua' } + buildXShellHeaders(base, Buffer.from('auth_token=abc;').toString('base64')) + + expect(base).toEqual({ 'User-Agent': 'ua' }) + }) +}) + describe('isUsableXDocument', () => { it('accepts a legacy shell exposing both the verification key and the ondemand chunk', () => { const d = doc(`${VERIFICATION_META}`) @@ -37,8 +60,9 @@ describe('isUsableXDocument', () => { expect(isUsableXDocument(doc(''))).toBe(false) }) - it('keeps a non-empty probe list with /home as the last-resort fallback', () => { - expect(X_LEGACY_HOME_URLS.length).toBeGreaterThan(1) - expect(X_LEGACY_HOME_URLS.at(-1)).toBe('https://x.com/home') + it('probes the canonical homepage first, with legacy routes as a guest fallback', () => { + expect(X_SHELL_URLS.length).toBeGreaterThan(1) + expect(X_SHELL_URLS[0]).toBe('https://x.com/home') + expect(X_SHELL_URLS).toContain('https://x.com/i/topics') }) }) diff --git a/verify/README.md b/verify/README.md index f81b3ddc..fbebf384 100644 --- a/verify/README.md +++ b/verify/README.md @@ -2,7 +2,7 @@ > AI 自验证体系 — AC 验收标准 + Vitest 三层测试架构 > 项目:[AnonTweet](..) -> 版本:0.3.1(2026-09-12 IG Story 接入 AC-IG-STORY-001~003;0.3.0 为验证诚信修复 F1~~F13:见 [backlog](../docs/planning/backlog.md) / +> 版本:0.3.2(2026-09-25 新增 AC-TWEET-011 外壳请求 cookie 回归防护;0.3.1 为 IG Story 接入 AC-IG-STORY-001~003;0.3.0 为验证诚信修复 F1~~F13:见 [backlog](../docs/planning/backlog.md) / > [review](../docs/reviews/review-2026-09-11-test-suite-honesty.md)) > 历史建设日志:[log.md](log.md)(2026-07~~08,勿当现行用法) @@ -73,7 +73,7 @@ AC-DEV-001(dev server 健康)、AC-IG-009(缺 cookies → 500)、AC-MEDI | 文档 | 覆盖 | | ---------------------------------------------------------- | ----------------------------------------------------- | -| [AC-tweet.md](acceptance-criteria/AC-tweet.md) | AC-TWEET-001~010(解析 + API + 搜索) | +| [AC-tweet.md](acceptance-criteria/AC-tweet.md) | AC-TWEET-001~011(解析 + API + 搜索 + 外壳鉴权) | | [AC-translation.md](acceptance-criteria/AC-translation.md) | AC-TRANS-001~007(占位符管线 + 视图/Provider) | | [AC-ig.md](acceptance-criteria/AC-ig.md) | AC-IG-001~009(IG 集成) | | [AC-ig-story.md](acceptance-criteria/AC-ig-story.md) | AC-IG-STORY-001~005(Story 提取/列表/下载/渲染/缓存) | diff --git a/verify/acceptance-criteria/AC-tweet.md b/verify/acceptance-criteria/AC-tweet.md index ade13828..6568f801 100644 --- a/verify/acceptance-criteria/AC-tweet.md +++ b/verify/acceptance-criteria/AC-tweet.md @@ -1,6 +1,6 @@ # Tweet API 验收标准 -> 版本:1.1 | 日期:2026-08-31(新增 AC-TWEET-009/010 搜索验收) +> 版本:1.2 | 日期:2026-09-25(新增 AC-TWEET-011 外壳请求 cookie 回归防护;1.1 为 AC-TWEET-009/010 搜索验收) > 对应 Postmortem:001 (Tweet Parsing), 005 (Media) > 关联 Verifier:`verify/modules/tweet.verifier.ts` > 执行命令:`bun verify --module tweet [--ac AC-TWEET-NNN]` @@ -133,19 +133,37 @@ --- -## 总计:10 条 AC - -| AC | 分类 | 依赖外部 API | 依赖 AI | -| ------------ | ----------- | ------------ | ------- | -| AC-TWEET-001 | 纯函数/离线 | 否 | 否 | -| AC-TWEET-002 | 纯函数/离线 | 否 | 否 | -| AC-TWEET-003 | 纯函数/离线 | 否 | 否 | -| AC-TWEET-004 | 纯函数/离线 | 否 | 否 | -| AC-TWEET-005 | 集成 | 是 (Twitter) | 否 | -| AC-TWEET-006 | 集成 | 是 (Twitter) | 否 | -| AC-TWEET-007 | 纯函数/离线 | 否 | 否 | -| AC-TWEET-008 | 集成 | 是 (Twitter) | 否 | -| AC-TWEET-009 | 纯函数/离线 | 否 | 否 | -| AC-TWEET-010 | 集成 | 是 (Twitter) | 否 | +## AC-TWEET-011:外壳请求携带登录 cookie(回归防护) + +- **输入**:`FetcherService`(`apiKey` = base64(`auth_token=…;ct0=…;twid=u%3D…;`));`axios.get` 被 spy, + 返回可用的 legacy 外壳 HTML(含 site verification meta + `ondemand.s`) +- **预期输出**:解析 transaction 文档时,对 X 外壳 URL 发出的请求头附带**解码后的 cookie**; + 未配置 `apiKey` 时不带 cookie +- **验证方法**:`bun run verify/index.ts --ac AC-TWEET-011` +- **前置条件**:无(mock,离线,不触网) +- **Pass 条件**: + - 首个请求 URL 为 `https://x.com/home`(`X_SHELL_URLS[0]`) + - `config.headers.cookie` 等于解码后的 cookie 字符串 + - 未配置 `apiKey` 时请求头不含 `cookie` 键 +- **回归来源**:上游 Rettiwt #885 曾以「抓外壳时附 cookie」修好同一错误,被 #888 合并时丢失。 + 本 AC 锁住「外壳请求必须带 cookie」这一不变量(见 [postmortem 013](../../docs/postmortem/013-upstream-frontend-drift.md)) + +--- + +## 总计:11 条 AC + +| AC | 分类 | 依赖外部 API | 依赖 AI | +| ------------ | --------------------- | ------------ | ------- | +| AC-TWEET-001 | 纯函数/离线 | 否 | 否 | +| AC-TWEET-002 | 纯函数/离线 | 否 | 否 | +| AC-TWEET-003 | 纯函数/离线 | 否 | 否 | +| AC-TWEET-004 | 纯函数/离线 | 否 | 否 | +| AC-TWEET-005 | 集成 | 是 (Twitter) | 否 | +| AC-TWEET-006 | 集成 | 是 (Twitter) | 否 | +| AC-TWEET-007 | 纯函数/离线 | 否 | 否 | +| AC-TWEET-008 | 集成 | 是 (Twitter) | 否 | +| AC-TWEET-009 | 纯函数/离线 | 否 | 否 | +| AC-TWEET-010 | 集成 | 是 (Twitter) | 否 | +| AC-TWEET-011 | 行为断言(mock/离线) | 否 | 否 | > 离线 AC 可通过 fixture 直接验证,无需网络;集成 AC 需要 `TWEET_KEYS` 环境变量。