# X（Twitter）账号抓取程序 · 技术文档

> 面向接手本系统的工程师。目标：不看代码即可理解端到端逻辑，并能按 `file:line` 定位实现。全部时间戳、字段名、行号均以源码为准；文中标「已更正」处表示原始模块分析有误、此处采信核查修订后的结论。

---

## 1. 概述

**这是什么。** `expert/X/` 是一个基于 [twikit](https://github.com/d60/twikit)（非官方 X GraphQL 客户端）的航天/卫星互联网 X 账号抓取器。它用一次性登录得到的 cookie 做无头登录，按种子清单批量拉取账号 profile 与时间线，把 twikit 的松散对象归一化后落进本机 SQLite（`x.sqlite3`），并可产出中英对照的 Markdown / HTML / PDF 报告。

**解决什么问题。** 为「Satellite Agent」（`agent/`，全球卫星互联网产业决策 agent）提供每日的 X 侧信号源。抓取器只负责「把推文抓进 SQLite」，agent 侧再只读增量地把新推文映射成事件（events），喂进 decide / 日报 / wiki 回写。两侧通过一份只读的 `x.sqlite3` 文件彻底解耦。

**当前数据规模。** SQLite 已存 **95 个账号 / 3123 条推文**（2026-06-06 实测，`expert/X/NEXT_STEPS.md:3`）；其中 5 个种子账号报 `user_not_found`（handle 已停用/改名，`NEXT_STEPS.md:54,97`）。账号按 9 大分类组织（见 §8）。

**部署状态。**
- 抓取器与 agent 入库链均为 **operator 本机 macOS 资产**，脚本硬编码 `/Users/john/InvesResearch/expert/X`（`run_x_scrape.sh:20`、`run_x_ingest.sh:20`）。**本 Linux 仓库只是代码副本**，`data/` 全部 gitignore，不在此机（`x.sqlite3`、`cookies.json`、快照、报告都不进 git）。
- Tier 2 整合（`XSqliteSource` + `x-ingest` action）已交付 2026-06-06，端到端实测 30 条推文真入库（`NEXT-STEPS.md:456`）。
- Tier 3（X → wiki **单向增量回写**，已更正：非「双向」）仍待启动（`NEXT-STEPS.md:501`）。目前 `x_to_wiki.py` 已具备，但作为 operator 手动脚本、不在调度链。

---

## 2. 系统架构

系统分两大块，共用底层 `expert/X/data/x.sqlite3`，但去向不同。

### 2.1 上游：expert/X 抓取库

```
航天相关X账号汇总表_豆包AI生成.xlsx   （种子账号清单：分类/账号名/@用户名/中文简介）
        │  accounts.load_seed_accounts()
        ▼
 cli scrape → run_scrape()  ──用 cookies.json 无头登录 twikit──▶  逐账号抓 profile + 时间线(Tweets/Replies)
        │  归一化（_normalize_user / _normalize_tweet / _normalize_media）
        ▼
   x.sqlite3   accounts / tweets / media 表（commit-per-account）
        │                    │
        │                    └── data/snapshots/run_NNNNN/*.json（原始快照，离线审计）
        ▼
 cli report → reports.py  →  report.md / report.html / report.pdf（中英对照）
```

翻译支线（`expert/X/scripts/`）：`dump_for_translate.py` 从 `tweets`/`accounts.bio` 筛出未译且非中文条目 → `data/translate_batches/batch_NN.json`（每 250 条一批）→ 人工/子 agent 译 → `data/translate_results/batch_NN_zh.json` → `import_translations.py` 写回 `translations` 表。译文旁挂、不改原文。

### 2.2 下游：agent 管道

agent 侧只读 `x.sqlite3`，绝不重抓、绝不写 X 的表。两条链路（沿用 `x-bridge-operator-runbook.md` 的字母约定，已更正原分析 A/B 对调）：

**链路 A — 轻量桥（进线上看板/日报，零服务器）**
```
run_x_scrape.sh → export_x_events.py（复用 XSqliteSource，近30天，脱敏5字段）
   → agent/data-live/x-events-archive.jsonl → git commit/push
   → GitHub Actions refresh_live_data → oaf.world 看板（每6h）+ 飞书日报（08:37）
```

**链路 B — 本机 agent + launchd（进本机 agent.db 的 events）**
```
launchd（daily）→ run_x_ingest.sh
   (1) satagent job run x-ingest-daily  = _action_x_ingest
         → XSqliteSource 只读增量读 tweets（first_seen_at > since）
         → ingest_from_source / ingest_raw：classify(5主线) + match_companies + extract_numeric
         → (url, occurred_at) 去重 → insert_event → agent events 表
   (2) x-freshness（断供监控，|| true）
   (3) daily-report（复用 weekly_report(7d)，|| true）
        events 表
          ├── decide()（decision job / /decide API 按需触发，不在链上）
          └── x_to_wiki.py（operator 手动，NER 命中 → 回写 wiki '## X 动态' 段）
```

**定时说明（已更正）。** `jobs.py:536` 注释与 `x-ingest-cron-runbook.md` 写的是 macOS launchd **03:00**；这是遗留的本机 launchd 时点。存在可移植入口 `run_job.sh`（从脚本位置反推仓库根、自动加载 `.env`，Linux/macOS 通用），`production-runbook.md:84` 记录的当前调度为 **06:45**（`45 6 * * * run_job.sh x-ingest-daily`）。以 06:45 为准，03:00 系历史值。抓取（`run_x_scrape.sh`）**故意不进任何调度链**——单次 30–60 分钟、撞 429 会 sleep 900s，与 1–2 秒的 ingest 量级不同，默认 operator 手动 1–2 天跑一次。

**关键隔离。** `x.sqlite3`（含 cookie / 全量原文）永不进 git；GitHub Actions 只见脱敏 JSONL。

---

## 3. 目录与文件地图

| 路径 | 作用 |
|---|---|
| `expert/X/x_agent/__init__.py` | 包初始化，**第一件事** `import _twikit_patch`，保证补丁在任何 `twikit.Client` 使用前生效（`__init__.py:2`） |
| `expert/X/x_agent/cli.py` | argparse 入口，6 个子命令；路径解析 + 参数拼装 + 打印，真正逻辑委托 db/scraper/reports |
| `expert/X/x_agent/accounts.py` | `SeedAccount` dataclass + `load_seed_accounts()`：从 xlsx 读种子清单 |
| `expert/X/x_agent/scraper.py` | twikit 抓取核心：登录、归一化、分页、单账号流程、run 编排、限流重试 |
| `expert/X/x_agent/_twikit_patch.py` | 对 twikit 2.3.3 的两处 monkey-patch（`get_indices` + `User.__init__`） |
| `expert/X/x_agent/db.py` | 全部 SQLite 持久化：5 表 schema + 连接管理 + 幂等 upsert |
| `expert/X/x_agent/reports.py` | 从 SQLite 渲染 md / html / pdf 三种交付物（只读） |
| `expert/X/scripts/dump_for_translate.py` | 翻译工作流第 1 步：导出待译批次（每 250 条） |
| `expert/X/scripts/import_translations.py` | 翻译工作流第 3 步：把译文写回 `translations` 表 |
| `expert/X/config.example.yaml` | 配置模板（复制成 `config.yaml`，gitignored） |
| `expert/X/pyproject.toml` | `requires-python>=3.10`；deps `twikit>=2.3.3` / `openpyxl>=3.1.5` / `pyyaml>=6.0.2`；`package=false`，靠 `python -m x_agent.cli` 跑 |
| `expert/X/航天相关X账号汇总表_豆包AI生成.xlsx` | 默认种子账号表 |
| `expert/X/data/`（gitignored） | `x.sqlite3` / `cookies.json` / `snapshots/` / `translate_batches/` / `translate_results/` / `reports/` / `scrape.log` |
| `expert/X/NEXT_STEPS.md` | 运维现状与踩坑记录 |
| `agent/satellite_agent/sources/x_sqlite.py` | `XSqliteSource`：只读增量消费 `x.sqlite3`，映射成 `RawEvent` |
| `agent/satellite_agent/sources/base.py` | `RawEvent` 契约（`base.py:17-27`） |
| `agent/satellite_agent/sources/__init__.py` | `load_source` 别名 `x-sqlite`/`x_sqlite`/`x` → `XSqliteSource`（`__init__.py:35`） |
| `agent/satellite_agent/jobs.py` | `_action_x_ingest`(188) / `_action_source_freshness_check`(451) / `_action_daily_report`(532) + `register_action`(604) |
| `agent/satellite_agent/ingest_pipeline.py` | `ingest_from_source`(119) / `ingest_raw`(29)：classify+enrich+去重+入库 |
| `agent/satellite_agent/decision.py` | `decide`(507) + 取数辅助 `_fetch_events`(52-64) |
| `agent/satellite_agent/ontology.py` | `Thread` 枚举（**5 主线**，`ontology.py:11-16`） |
| `agent/scripts/run_x_scrape.sh` | operator 手动 scrape 封装（预检 + 透传 `x_agent.cli scrape`） |
| `agent/scripts/run_x_ingest.sh` | launchd 入口，串 x-ingest → x-freshness → daily-report |
| `agent/scripts/run_job.sh` | 可移植 job 入口（取代硬编码 `/Users/john`） |
| `agent/scripts/x_to_wiki.py` | X → wiki `## X 动态` 段回写 + 新账号起草（operator 手动） |
| `agent/scripts/export_x_events.py` | X → 脱敏 JSONL 导出桥（链路 A，operator 手动） |
| `agent/scripts/backfill_x_occurred_at.py` | 一次性把历史 `events.occurred_at` 回填成 ISO |
| `agent/docs/x-ingest-cron-runbook.md` · `x-bridge-operator-runbook.md` · `production-runbook.md` | 运维手册 |

---

## 4. 数据模型（SQLite）

DDL 集中在 `db.py:11-94`（`SCHEMA`，全 `IF NOT EXISTS`，`init_schema` 可反复执行）。共 5 表 + 3 索引。所有时间戳由 `utcnow_iso()`（`db.py:97-98`）生成：UTC、秒级、`+00:00` 偏移的 ISO8601。

### 4.1 accounts — 账号（`db.py:12-29`）

主键 `username`。字段分三组：抓取回填、种子导入、运维状态。

| 字段 | 类型 | 含义 |
|---|---|---|
| `username` | TEXT PK | 去 `@` 的用户名（种子导入时建行的键） |
| `user_id` | TEXT | X 数字 user id（回填，`COALESCE` 保护不被 NULL 覆盖） |
| `display_name` | TEXT | 显示名（回填，来自 `name`） |
| `bio` | TEXT | 简介原文（回填，来自 `description`） |
| `followers_count` | INTEGER | 粉丝数（回填） |
| `following_count` | INTEGER | 关注数（回填，`following_count or friends_count`） |
| `tweets_count` | INTEGER | 发帖数（回填，来自 `statuses_count`） |
| `verified` | INTEGER | 0/1；`verified OR is_blue_verified`（蓝标也算已验证） |
| `profile_created_at` | TEXT | 账号注册时间（回填） |
| `location` | TEXT | 资料地区（回填） |
| `url` | TEXT | 资料链接（回填） |
| `source_category` | TEXT | **种子表分类标签**（9 大分类，供 agent source 标签） |
| `source_name` | TEXT | 种子表账号名 |
| `source_bio_zh` | TEXT | 种子表中文简介 |
| `last_scraped_at` | TEXT | 末次抓取时间（成功/失败都写） |
| `last_error` | TEXT | 末次错误（截断 500 字，成功时清空） |

无外键。无建行时间字段——无法从表本身区分账号「何时首次抓到」。

### 4.2 tweets — 推文（`db.py:31-55`）

主键 `id`（TEXT）。唯一外键 `author_username → accounts(username)`（`db.py:54`）。三索引：`idx_tweets_author` / `idx_tweets_created_at` / `idx_tweets_type`（`db.py:57-59`）。

| 字段 | 类型 | 含义 |
|---|---|---|
| `id` | TEXT PK | 推文 id（强转 str，必需键） |
| `author_username` | TEXT NOT NULL | **时间线拥有者**（种子账号）；RT 时不是原作者，必需键 |
| `author_user_id` | TEXT | `tweet.user.id` |
| `text` | TEXT | `full_text or text` |
| `created_at` | TEXT | 推文原始时间（Twitter ctime 风格，非 ISO） |
| `lang` | TEXT | 语言码 |
| `tweet_type` | TEXT | `original` \| `retweet` \| `quote` \| `reply`（`_classify` 判定，RT 优先） |
| `in_reply_to_tweet_id` | TEXT | 被回复推文 id |
| `in_reply_to_username` | TEXT | **⚠️ 名不副实：实际存 `in_reply_to_user_id_str`（数字 user id，非 handle）**（`scraper.py:156`） |
| `retweeted_tweet_id` | TEXT | RT 的原推 id（不存原作者） |
| `quoted_tweet_id` | TEXT | 引用推 id |
| `favorite_count`/`retweet_count`/`reply_count`/`quote_count`/`view_count`/`bookmark_count` | INTEGER | 6 个互动计数，原样搬 |
| `urls_json` / `hashtags_json` | TEXT | `json.dumps(... or [], ensure_ascii=False)` 序列化 |
| `scraped_at` | TEXT | **末见时间**（每次 upsert 刷新为 now） |
| `first_seen_at` | TEXT | **首见时间**（增量判定水位，见 §4.5） |
| `run_id` | INTEGER | **首见的那次 run**（不随复看刷新） |

### 4.3 media — 媒体（`db.py:61-71`）

复合主键 `(tweet_id, media_key)`，均 NOT NULL。**无到 `tweets(id)` 的外键**——即便 `foreign_keys=ON` 也可能写入孤儿媒体行。

| 字段 | 类型 | 含义 |
|---|---|---|
| `tweet_id` | TEXT PK | 所属推文 id |
| `media_key` | TEXT PK | 媒体键（`media_key`\|`id_str`\|`id`；为空则 skip） |
| `type` | TEXT | `photo` \| `video` \| `animated_gif` |
| `url` | TEXT | 图片直链，或视频最高码率 mp4 直链（`_best_video_url`） |
| `preview_url` | TEXT | 恒取图片 url（视频的 preview 是缩略图） |
| `width` / `height` | INTEGER | 优先 `sizes.large`，退回 `original_info` |
| `duration_ms` | INTEGER | 视频时长（`video_info.duration_millis`） |

### 4.4 translations — 译文（`db.py:73-81`）

复合主键 `(kind, ref_id)`。`db.py` 本文件**只有 DDL、无任何读写函数**——写入在 `expert/X/scripts/import_translations.py`（`INSERT OR REPLACE`，`engine='claude-code-subagent'`），读取在 `reports.py` 与 `x_sqlite.py`。

| 字段 | 类型 | 含义 |
|---|---|---|
| `kind` | TEXT NOT NULL | `'tweet'` \| `'bio'` |
| `ref_id` | TEXT NOT NULL | `tweet.id` 或 `account.username` |
| `source_lang` | TEXT | 源语言 |
| `text_zh` | TEXT NOT NULL | 中文译文 |
| `engine` | TEXT | 译法标记 |
| `created_at` | TEXT | 译入时间 |

> 已更正：主键仅保证「每个 (kind, ref_id) 至多一行」；重复翻译是覆盖还是抛 UNIQUE 约束错，取决于外部写入方的语句（本表在 `db.py` 内不可见）。实际写入方用的是 `INSERT OR REPLACE`，故为覆盖。

### 4.5 runs — 抓取运行账本（`db.py:83-93`）

| 字段 | 类型 | 含义 |
|---|---|---|
| `id` | INTEGER PK AUTOINCREMENT | run id（`upsert_tweet` 用它标记推文首见的 run） |
| `started_at` | TEXT NOT NULL | 开始时间（`start_run`） |
| `finished_at` | TEXT | 结束时间；**NULL = run 未正常收尾（中断/崩溃）**，据此判僵尸 run |
| `accounts_total`/`accounts_ok`/`accounts_failed` | INTEGER | 账号计划/成功/失败数 |
| `tweets_new`/`tweets_seen` | INTEGER | 新增/复看推文计数 |
| `notes` | TEXT | 备注 |

### 4.6 first_seen_at 增量判定（核心不变量）

`upsert_tweet`（`db.py:222-276`）的语义是整个增量体系的基石：

1. **is_new 靠插入前一次 `SELECT 1 FROM tweets WHERE id=?` 判定**（`db.py:230`）：结果为 `None` → 新增。同连接内即使未 commit 也能自见，故账号内跨 tab 去重可靠。
2. **`ON CONFLICT(id) DO UPDATE` 冲突分支只更新 `text` + 6 个互动计数 + `scraped_at=now`**；`first_seen_at` 与 `run_id` **刻意不在更新列表**（`db.py:241-249`）。
3. 结果：`first_seen_at` 恒为首见时间、`scraped_at` 刷成末见时间、`run_id` 恒为首见的 run。下游 agent 正是靠 `first_seen_at > since` 做开区间增量（见 §8.1）。

---

## 5. 核心抓取逻辑（scraper.py）

`ScrapeConfig`（`scraper.py:30-37`，frozen dataclass）汇集抓取参数，默认：`tweets_per_account=40`（**每个 tab 的目标条数**，非每账号总量——开 replies 时单账号原始最多约 80）、`include_replies=True`、`include_retweets=True`、`sleep_between_accounts=(3.0,7.0)`、`rate_limit_sleep=900`、`max_retries=3`。

### 5.1 登录 / cookie

两条路径，登录态与抓取解耦，凭证只在登录时用（符合项目「凭证只在 provider/网关/env 层」不变量）：

- **`interactive_login`**（`scraper.py:53-73`）：一次性交互登录。`Client.login(auth_info_1, auth_info_2, password, totp_secret, cookies_file=path)` → 显式 `save_cookies` 双保险。`auth_info_1`=主标识（用户名/邮箱/手机），`auth_info_2`=挑战备用标识（可空），`totp_secret` 支撑 2FA（可空）。产物是 cookie JSON。
- **`make_client`**（`scraper.py:42-50`）：无头路径。`Client(language)` → 若 cookie 文件不存在直接 `RuntimeError` 提示先 `cli login`（`scraper.py:44`）→ `load_cookies`。声明为 async 但内部无 await，只为调用风格一致。不带任何明文凭证。

Cookie 复用而非每次带密码：登录挑战 / 2FA 只走一次，之后所有无头 run 靠 cookie，规避风控与重复挑战。

### 5.2 归一化层（twikit 松散对象 → 稳定 dict）

全部字段走 `getattr(..., None)` / `.get(default)` 容错，X 掉字段也不崩：

- **`_classify`**（`scraper.py:78-85`）：`retweeted_tweet`→`retweet`；否则 `in_reply_to`→`reply`；否则 `quoted_tweet`→`quote`；否则 `original`。优先级即代码顺序。
- **`_best_video_url`**（`scraper.py:88-95`）：从 `video_info.variants` 里挑 `content_type=='video/mp4'` 且有 `bitrate` 的、按 `bitrate` 取 max 返回 url。
- **`_normalize_media`**（`scraper.py:98-137`）：视频/gif 走 `_best_video_url`，图片走 `media_url_https`；`preview_url` 恒取图片 url。
- **`_normalize_tweet`**（`scraper.py:140-168`）：`author_username` 恒为传入种子账号（RT 时非原作者）；`in_reply_to_username` 实际存数字 user id（见 §4.2）。
- **`_normalize_user`**（`scraper.py:171-183`）：`following_count or friends_count` fallback，配合补丁把 `following_count` 映射成 `legacy.friends_count`；`verified = verified OR is_blue_verified`。

### 5.3 分页（`_fetch_timeline`，`scraper.py:188-202`）

`kind ∈ {'Tweets','Replies'}`。`await user.get_tweets(kind, count)` 拿首页 → 循环 `result.next()` 直到累计 `len >= count`（X 实际每页约 20，`count` 只是页大小提示，必须靠 `next()` 翻页凑够 40），末尾 `out[:count]` 截断。**⚠️ 关键不对称**：首页 `get_tweets`（191 行）**不套 try**，异常向上抛；分页 `result.next()`（196 行）被**裸 `except Exception` 吞掉直接 break**——翻页途中撞 429/任何错都只是「提前截断」，不触发退避重试。

### 5.4 单账号流程（`scrape_account`，`scraper.py:205-270`）

返回 `(tweets_new, tweets_seen, error_or_None)`：

1. `get_user_by_screen_name`（218 行）：本地 try 只捕 `UserNotFound/NotFound`→`(0,0,'user_not_found')`、`Unauthorized`→`(0,0,'unauthorized: ..')`（221-222 行，软失败不抛）。**注意此 try 不含 `TooManyRequests`**，故 get_user 抛的 429 会冒泡到 `run_scrape` 退避分支（已更正原分析「退避只对首页 get_tweets 生效」）。
2. `_normalize_user` → `db.upsert_account_profile`（225 行）。
3. 抓 Tweets tab（232 行，**不套 try**）；若 `include_replies` 再抓 Replies——**仅此处套 `try/except NotFound`**（235-241 行），Replies 端点 404 时 warn 并保留 Tweets-only 数据。Tweets tab 的 `NotFound` 会冒泡成整账号失败。
4. **写库循环**（247-258 行，两个 tab 都 fetch 完之后才执行）：逐条 `_normalize_tweet`（套 try，坏推文 warn+skip）→ pop media → `db.upsert_tweet` 返回 `is_new` → `db.upsert_media` → 累加 new/seen。
5. 写 `{snapshot_dir}/{username}.json`（`{profile, tweets}`）。失败早返回的账号不写快照。

### 5.5 run 编排（`run_scrape`，`scraper.py:275-364`）

1. **`--only` 过滤**：算 `wanted` 集合，对不在 seed 清单的账号**显式 warn**（286-294 行，修此前静默跳过让 operator 误判的已知问题 #1）；全不命中 → `SystemExit`。
2. `make_client` → `db.connect`（WAL + `foreign_keys=ON`）→ `init_schema` / `seed_accounts` / `start_run` → `run_id`；建 `run_{run_id:05d}` 快照目录。
3. **逐账号重试循环**：
   - 正常路径拿 `(new,seen,err)`；`err` 则 `failed++` + `mark_account_error`，否则 `ok++` 累加，break。
   - `except TooManyRequests`（327-335 行）：`attempt >= max_retries` 则 `failed` + mark `'rate_limited'` 放弃；否则 `attempt++`、`asyncio.sleep(rate_limit_sleep)` 后**从 get_user 重跑整个账号**（因 upsert 幂等，数据不重复）。
   - `except (BadRequest, TwitterException)`：`failed` + mark + `log.exception` + break。
   - `except Exception`：兜底 `failed` + mark + break，绝不掀翻整轮。
4. **commit-per-account**（347 行）：每账号后 `conn.commit()` 做 checkpoint，配合 WAL 让 stats/report 在 run 进行中就读到已完成账号；中途崩溃不丢前面成果。
5. 账号后 `random.uniform` 抖动 sleep；全部完 `finish_run`，返回 `{run_id,ok,failed,tweets_new,tweets_seen,snapshot_dir}`。

### 5.6 twikit 补丁（`_twikit_patch.py`）

由 `__init__.py:2` 在任何 `Client` 使用前 import 生效（**强依赖此导入路径**：绕过包直接 `import twikit.Client` 则补丁不生效，每请求都 `Couldn't get KEY_BYTE indices` 全线失败）。两处补丁：

- **`get_indices`**（`_twikit_patch.py:22-52`）：X 2026-03 后把 `ondemand.s` 拆成 index+hash 两段，旧单正则失配（upstream #408）。新逻辑先 `_NEW_FILE_REGEX` 抓 index、再 `_HASH_PATTERN.format(index)` 抓 hash，拼 `ondemand.s.{hash}a.js` 再解析 KEY_BYTE 索引。
- **`User.__init__`**（`_twikit_patch.py:60-100`）：X 常省略可选字段，上游裸 `dict[...]` 下标一缺就崩（#417），替换为全字段 `.get(default)` 版，含 `following_count = legacy.friends_count` 映射（`_twikit_patch.py:89`）。

### 5.7 限流 / 重试要点与陷阱

- **退避覆盖范围（已更正）**：`rate_limit_sleep=900` 的退避对**任何从 `scrape_account` 冒泡出的 `TooManyRequests`** 生效——包括 `get_user_by_screen_name`(218)、两个 tab 的首页 `get_tweets`(232/235)；**唯独分页 `result.next()`(196) 的 429 被裸 except 吞掉**（首页 429 完整退避、翻页 429 静默少抓，行为不对称）。单个被限流账号最坏 3×900s ≈ 45 分钟串行阻塞整轮；twikit 自身还会 auto-sleep，二者叠加。
- **限流失败账号不落半份推文（已更正）**：写库循环在两个 tab 都 fetch 完之后才跑，而 429 只可能在 get_user/首页 get_tweets 抛出——都在写库之前。故限流失败的账号最多落 profile 行（225 行）+ `mark_account_error`，**不写任何推文行**。「提交半份推文」只发生在**写库循环内部抛错**的路径（`db.upsert_tweet/upsert_media` 中途异常，走 `BadRequest/TwitterException/Exception` 分支），此时 347 行的 commit 会持久化本轮已写入的部分推文。
- **cookie 过期无全局熔断（已更正机制）**：cookie 失效时第一个网络调用 `get_user_by_screen_name`(218) 先抛 `Unauthorized` → 被 221-222 本地捕获成软错误 `err='unauthorized: ..'` → `failed++`（走不到 get_tweets/TwitterException 分支）。run 不会尽早 abort，会把整份 seed 逐个跑成 failed，浪费一整轮。
- **`include_retweets` 是「已接线但未被消费」的死配置（已更正）**：它由 `config.yaml`(`config.example.yaml:24`) → `cli.py:49` → `ScrapeConfig`(`scraper.py:34`) 完整接线，但**抓取逻辑内无任何 `cfg.include_retweets` 分支**读取它（原始时间线本就含 RT），改动它确实无行为效果——但它不是「零引用的孤立声明」。
- **seen 跨 tab 重复**：同一推文同时出现在 Tweets 和 Replies tab 时，`upsert` 按 id 去重（new 只 +1），但 `seen_count` 每条原始行都 +1（`scraper.py:257`）→ `tweets_seen` 大于去重后真实推文数。

---

## 6. CLI 与配置

进程唯一入口 `main`（`cli.py:263-271`）：`build_parser().parse_args` → 按 `-v` 设 log 级别 → `_load_config` → `_resolve_paths` → `args.func(args, cfg, paths)`。所有子命令统一签名 `func(args, cfg, paths) -> int`，返回值即退出码。`cfg`/`paths` 只算一次。

### 6.1 子命令（`build_parser`，`cli.py:223-260`）

| 子命令 | 参数 | 处理函数 | 作用 |
|---|---|---|---|
| `init` | 无 | `cmd_init`(58) | 从 xlsx 种子表灌 `accounts`（`load_seed_accounts` → `connect` → `init_schema` → `seed_accounts`） |
| `login` | `--username/--email/--password/--totp` | `cmd_login`(116) | 交互登录 X 一次，产出 `cookies.json` |
| `import-cookies` | 位置参 `path` | `cmd_import_cookies`(98) | 把浏览器导出的 cookie JSON 归一后写 `cookies.json` |
| `scrape` | `--only`(nargs+)、`--tweets-per-account`(int) | `cmd_scrape`(141) | 抓全部/指定种子账号的 profile+推文 |
| `stats` | 无 | `cmd_stats`(184) | 只读打印 DB 汇总 |
| `report` | `--out-dir`、`--no-pdf` | `cmd_report`(164) | 从 DB 渲染 md/html/(pdf) |

- `cmd_scrape`（`cli.py:141-161`）：`--tweets-per-account` 非 None 时用 dataclass `__dict__` 展开重建 `ScrapeConfig` 覆盖该字段（144-145 行）；`--only` 未命中账号显式 warn、全不命中 `SystemExit`。
- `cmd_stats`（`cli.py:184-218`）：聚合 accounts（总数/已抓/有错）、tweets 总数、按 `tweet_type`、按 `source_category`（**按各分类账号数降序**，`ORDER BY c DESC`，已更正措辞）、最新 run。
- `cmd_report`（`cli.py:164-181`）：`out_dir = args.out_dir or data_dir/'reports'`；调 `reports.write_reports(..., make_pdf=not args.no_pdf)`，PDF 缺 Chrome 时打印 `(skipped — Chrome not found or render failed)`。

### 6.2 config.yaml 字段

`_load_config`（`cli.py:26-30`）缺文件返回 `{}`（配置全可选）；`_resolve_paths`（`cli.py:33-41`）每键 `cfg.get(k) or 默认`，开箱即用。

| 配置键 | 默认 | 说明 |
|---|---|---|
| `data_dir` | `ROOT/data` | 输出根（`ROOT`=`expert/X`，`cli.py:20`） |
| `db_path` | `data_dir/x.sqlite3` | SQLite 路径 |
| `cookies_path` | `data_dir/cookies.json` | 登录态文件 |
| `snapshots_root` | `data_dir/snapshots` | JSON 快照根 |
| `xlsx_path` | `ROOT/航天相关X账号汇总表_豆包AI生成.xlsx` | 种子表 |
| `credentials.username/email/password/totp_secret` | — | 仅 `login` 用；也可走 CLI 参数或环境变量 `X_USERNAME/X_EMAIL/X_PASSWORD/X_TOTP_SECRET`（优先级 args > config > env） |
| `scrape.tweets_per_account` | 40 | 每 tab 目标条数 |
| `scrape.include_replies` | **示例配置 `false`**（`config.example.yaml:23`，因 2026-06-08 端点 404）；代码 dataclass 默认 `True` | 是否抓 Replies tab |
| `scrape.include_retweets` | `true` | **死配置**，见 §5.7 |
| `scrape.sleep_between_accounts` | `[3.0, 7.0]` | 账号间抖动区间（秒），yaml list 转 tuple |
| `scrape.rate_limit_sleep` | 900 | 命中 429 每次退避时长（秒） |
| `scrape.max_retries` | 3 | 仅对 `TooManyRequests` 生效 |

> ⚠️ 路径派生耦合：`db_path/cookies_path/snapshots_root` 默认相对 `data_dir`；若只改 `db_path` 不改 `data_dir`，report 输出仍落 `data_dir/reports`、cookies 仍落 `data_dir/cookies.json`，易路径分裂。

### 6.3 cookie 导入（`_extract_from_browser_export`，`cli.py:72-95`）

支持 3 种导出格式归一成 `{name:value}`：(1) 已是 `{name:value}` 的 dict（要求所有 value 是 str，`cli.py:76`）；(2) twikit `save_cookies` 格式（带 `'cookies'` 列表键）；(3) 标准 list（Cookie-Editor / EditThisCookie / Get cookies.txt LOCALLY）。list 每条按 `_X_DOMAINS`（`x.com/.x.com/twitter.com/.twitter.com`，`cli.py:68`，`endswith` 去点匹配）过滤，无 domain 字段一律保留。必需 cookie `_REQUIRED_COOKIES={auth_token, ct0}`（`cli.py:69`）；缺则 WARN 不 fail（import 成功不代表能登录，真失败要到 scrape 才暴露）。

### 6.4 种子加载（`accounts.py`）

`load_seed_accounts`（`accounts.py:18-44`）：`openpyxl.load_workbook(path, data_only=True)`（读公式计算值）→ `wb.active`（**只读活动 sheet**，活动表不对会读错表）→ 首行作表头，前 4 列 strip 后必须严格等于 `('分类','账号名称','@用户名','简介核心内容')`，否则 `ValueError`（`accounts.py:23-25`）。逐行：`username = str(handle).strip().lstrip('@')`，按去 @ 用户名 `seen` 去重。产出 `list[SeedAccount(category, name, username, bio_zh)]`（frozen dataclass，`accounts.py:10-15`）。`db.seed_accounts` 用 `ON CONFLICT(username) DO UPDATE` 只覆盖 `source_*` 三列，重导种子不清已抓数据（`db.py:119-136`）。

---

## 7. 报告生成（reports.py）

顶层入口 `write_reports`（`reports.py:735`）：建 `out_dir` → `collect()` 得 `Report` → `render_markdown` 写 `report.md`、`render_html` 写 `report.html`（均 utf-8）→ 若 `make_pdf`，`render_pdf` 用 headless Chrome 打开刚落盘的 html 生成 `report.pdf` → 返回 `(md_path, html_path, pdf_path|None)`。md/html 必出，pdf 可缺。

**`collect`（`reports.py:118`，唯一读库处）**：调 `db.connect(db_path)`（**不传 kwarg**，Row 行工厂由 `db.connect` 内部配置，已更正原分析）。6 条 SQL：translations→`(kind,ref_id)->text_zh`(122)；media→`tweet_id->[media]`(126)；accounts `ORDER BY source_category,username`→`Account`+join bio 中译(132)；tweets `ORDER BY author_username,created_at DESC`→`Tweet`+join 译文/媒体、挂回 `Account.tweets` 与 `all_tweets`(153-172)；`tweet_type,COUNT(*)` 类型分布(181)；`runs ORDER BY id DESC LIMIT 1` 最近 run(184)。派生：`by_category` 按分类分桶后桶内按 followers 降序(174-178)；`scraped=有 last_scraped_at 且无 error`、`failed=有 error`(187-188)；`top=all_tweets 按 score 降序取 30`(191)。渲染层不再碰 DB。

**报告内容结构**（`render_markdown` `reports.py:239` / `render_html`+`HTML_TMPL` `reports.py:635/348`）：
1. 统计头部：生成时间 / 数据源 / 最近 run / 账号数（成功/失败）/ 推文总数与类型分布。
2. 目录（锚点）。
3. **全局 Top 推文**：`all_tweets` 按 `Tweet.score` 降序取 `TOP_TWEETS_GLOBAL=30`，逐条 @作者+时间+指标+原文+🇨🇳译文。
4. **按分类展开**：每类标题+账号数；每账号 handle+verified+统计+location+bio+bio_zh+错误，再列该账号推文按 score 降序。
5. **失败账号表**：用户名 | 分类 | 错误（截断）。

**关键实现点与坑：**
- **`Tweet.score`**（`reports.py:69`）：`view_count or (favorite*3 + retweet*5 + reply*1 + quote*2)`。有浏览数与无浏览数两组量级混排；`view_count==0` 会误回退到加权分。
- **`TOP_TWEETS_PER_ACCOUNT=999`**（`reports.py:43`）：实质不限量，每账号全部推文入报告，大账号会撑爆 HTML。
- **两格式截断不一致**：HTML 推文正文全文不截（`white-space:pre-wrap`）；Markdown 与全局 Top 截 `TWEET_TEXT_PREVIEW=600` 字。`_trim`（`reports.py:230`）是**把换行替换成空格**再 strip（已更正，非「去换行」）+ 截 600 加省略号。
- **分类数是数据决定的**（已更正）：分类由 `accounts.source_category` 数据决定、非代码写死；顺序=SQL `ORDER BY source_category` 字典序；缺失兜底 `'(未分类)'`（`reports.py:145`）。源码无常量固定为「9」（9 是当前种子数据的经验值）。
- **媒体是远程 `<img src>` 非内嵌**（`reports.py:566`）：photo 用 `url`、video/gif 用 `preview_url`，均来自 DB 的 `media.url/preview_url` 原样放入（域名如 `x.com/pbs.twimg.com` 属推测、代码无字面量）；在线可能 404，PDF 中被 `@media print` 隐藏（`reports.py:438`）。
- **HTML 自包含**：CSS(354)+JS(509) 全内联，含深/浅/print 三主题；唯一外链是媒体图。JS 支持搜索过滤、中英显隐（`body.hide-en/hide-zh`）、`?expand=1`/`beforeprint` 展开全部 `<details>`。
- **PDF 靠本机 Chrome**（`render_pdf` `reports.py:708` / `_find_chrome` `reports.py:32`）：先查 macOS 四个 `.app` 路径再 `shutil.which(google-chrome/chrome/chromium/chromium-browser)`；命令 `chrome --headless=new ... --print-to-pdf=<pdf> file://<html>?expand=1`，`timeout=180`。成功判定=文件存在且 `size>0`；出错记 stderr 前 500 字。找不到 Chrome → 静默降级只出 md+html，`pdf_path=None`。
- **`scraped+failed ≠ total`**（`reports.py:187-188`）：从未抓过的账号（无 `last_scraped_at` 无 error）两边都不计。

---

## 8. 下游集成：接入 Satellite Agent

`XSqliteSource`（`x_sqlite.py:136`）把 `x.sqlite3` 作为一个只读、增量的 Source 接进 agent 抓取层。

### 8.1 增量映射（`XSqliteSource.fetch`，`x_sqlite.py:217-317`）

1. **只读打开**（`_open_ro`，`x_sqlite.py:175-202`）：db 不存在 → 设 `last_error` 返回 None（降级不抛）。3 段 fallback：`file:{abs}?mode=ro&immutable=1`（WAL 下唯一稳，假设无并发写、读到 last commit 不抢锁；URI 在 186 行）→ `file:{abs}?mode=ro` → 普通 connect。`row_factory=sqlite3.Row`。
2. **增量 SQL**：`WHERE t.text IS NOT NULL AND t.text <> ''`(226) 恒成立，若 `since` 非空再加 `(t.first_seen_at IS NULL OR t.first_seen_at > ?)`(228，**开区间；`first_seen_at` 为 NULL 的行永远放过**——可能被反复 emit，靠下游去重挡)。`tweets t LEFT JOIN accounts a`，取 `id/username/text/created_at/lang/tweet_type/first_seen_at` + `a.source_category/a.display_name`。`ORDER BY t.first_seen_at DESC NULLS LAST, t.created_at DESC`(237，老 SQLite 不支持 `NULLS LAST` → 去词 fallback 重跑，244-247)，`LIMIT self.limit`（source 默认 **500**，job 默认 **200**）。
3. **过滤**：按 `tweet_type` 过滤 reply/retweet（默认都过滤，`include_replies/include_retweets` 默认 False，268-274）；按 `categories` 白名单过滤（276-279）；二次空文本过滤（281-284）。
4. **中英拼接**（286-291）：若 `translations`（`kind='tweet'`）有 zh 译且非空、不等于原文，`text = 原文 + "\n\n[zh] 译文"`，让 classifier 同看中英文（提升中文命中）。`translations` 表缺失则吞 `OperationalError`。
5. **映射成 `RawEvent`**（304-314，契约见 `base.py:17-27`）：
   - `title=_make_title(text)`（原文首行截 80 字）；
   - `text=body`；若 `display`（`display_name or username or ""`，已更正：display_name 缺失时回退 username，`x_sqlite.py:295`）非空且不在 body 里，前缀 `[display] `；
   - `source=source_label`（见 §8.2）；`url=https://x.com/{username}/status/{id}`（无 username 用 `/i/web/status/`）；
   - **`occurred_at=_to_iso(row['created_at'])`（310，来自推文 `created_at` 而非 `first_seen_at`）**；
   - `next_indicators=[]`、`numeric_overrides={}`、`companies=[]` 全空（让下游 `match_companies` 兜底、`extract_numeric` 自动提取）。
6. **`_to_iso`**（`x_sqlite.py:40-71`）：把 Twitter ctime 风格 `'Wed May 20 13:00:33 +0000 2026'`（年份在末尾，**非标准 RFC 2822**，仅 `email.utils.parsedate_to_datetime` 容忍）→ ISO `'YYYY-MM-DDTHH:MM:SS'`。None/空 → None；已含 `'T'` 且不以周几开头当作已 ISO 原样返回（幂等）；解析失败 → None（`occurred_at` 留空，decide 的 7d/30d 窗口会跳过该事件）；tz-aware 转 UTC 后去 offset。

### 8.2 9 分类 → 主线映射（关键设计：不映射）

`KNOWN_CATEGORIES`（`x_sqlite.py:77-87`）是 9 大账号分类白名单常量（来自 `accounts.source_category`）：全球卫星运营商 / 其他细分航天企业 / 军工航天巨头 / 各国官方航天机构 / 在轨专项航天项目账号 / 民营航天企业（美国）/ **航天从业者/宇航员**（已更正：斜杠非中间点）/ 航天媒体&资讯博主 / 非洲航天相关。

**这 9 分类不映射到 agent 的主线**——只拼进 source 标签 `X · {category} · @{handle}`（298-302），供 events 表反查「这条推来自哪个分类哪个账号」。`categories` 参数（None=不限）只作白名单过滤器。**主线判定完全由下游 classifier 读 text 决定**——因为媒体博主 / 在轨专项等账号推文常跨主线，强绑会误分。

> ⚠️ 主线数（已更正）：agent 本体 `ontology.Thread` 现有 **5 主线**——核心网 / 终端 / 芯片 / 运营支撑 / 运载发射（`ontology.py:11-16`，2026-06-08 新增 `LAUNCH_VEHICLE`）。`x_sqlite.py:75-76` 与 `ontology.py:1` docstring 仍写「4 主线」，系遗漏更新，**以 5 主线为准**。这不影响 `XSqliteSource` 行为（它本就不做 category→主线映射）。

### 8.3 x-ingest cron 链

**24h 时序**（抓取是 operator 手动触发、不在时钟上；产出 `x.sqlite3` 后两条链路按固定时刻各自消费。时刻为各执行主机本地时钟，Actions 标北京时间；`launchd(本机)` 与 `ECS cron(生产)` 是链路 B 的两套独立部署，生产库靠 03:30 rsync 从本机同步）：

| 时刻 | 执行者 | 动作 |
|---|---|---|
| **ad-hoc**（建议 1–2 天/次） | operator 本机 | `run_x_scrape.sh` 抓 95 账号（30–60 分钟，429→sleep 900s）→ `x.sqlite3`；随后 `export_x_events.py` 脱敏 → `x-events-archive.jsonl` → `git push main` |
| **每 6h**（00:45/06:45/12:45/18:45 北京） | GitHub Actions | `data-refresh`：`refresh_live_data.py` 读 jsonl → `docs/sample/*.json` → Cloudflare Pages → oaf.world 看板（永不接触 `x.sqlite3` 本体） |
| **03:00**（本机） | launchd | `run_x_ingest.sh`：(1) `x-ingest-daily`(`limit=500` 无 since) → 本机 `agent.db` events →(2)`x-freshness`→(3)`daily-report`，各步 `|| true`、仅主步透传退出码 |
| **03:30**（本机） | operator crontab | `rsync x.sqlite3` → 生产服务器（`production-runbook.md:147`） |
| **06:45**（服务器） | ECS cron | `run_job.sh x-ingest-daily`（`since=auto` 增量）→ 服务器 events |
| **07:00**（服务器） | ECS cron | `x-freshness`：X 源最新事件 `created_at` 距今 >48h → job exit 1 + 飞书红卡（注册须 `--max-failures 999`） |
| **08:37**（北京） | GitHub Actions | `daily-feishu`：读同一份 jsonl，命中主线的 `X ·` 条目进次日早报候选池 |

各 action 明细：

- **`_action_x_ingest`**（`jobs.py:188-243`，`register_action("x-ingest", ...)` @604）：读 params（`db_path/since/since_auto_margin_hours/limit(默认200)/include_replies/include_retweets/categories/use_llm`）；`since=='auto'` 时先 `resolve_since_auto` 解水位（217-221）→ 构造 `XSqliteSource` → `ingest_from_source(conn, src, llm)` 入 events（236）→ 把 `src.stats`/`since_resolved`/`last_error` 挂 result 留痕。SQLite 不存在 → source 静默 yield 0，job 仍标 success。
- **`resolve_since_auto`**（`x_sqlite.py:106-133`）：`SELECT MAX(occurred_at) FROM events WHERE source LIKE 'X ·%'`（121-123）→ 无 X 事件 / 解析失败 → None（退全量，宁多扫不漏）；否则 `watermark - margin_hours`（默认 24h）作重叠缓冲。**语义偏移**：`events.occurred_at` 是推文 `created_at`，而 source 的 `since` 比的是 `first_seen_at`（`seen ≥ created`）；用 `occurred_at` 水位 +margin 近似，漏抓风险被 margin 吸收、重叠由去重消化。
- **`ingest_from_source` / `ingest_raw`**（`ingest_pipeline.py:119` / `29`，已更正：逐条入库函数是 `ingest_raw`，非「process_raw_event」）：逐条 `classify(text)` 判主线 → `match_companies` 反哺 → 可选 LLM 兜底 → `extract_numeric` → `insert_event`。**去重**用 `find_event_by_url_and_time(url=raw.url, occurred_at=raw.occurred_at)`（`ingest_pipeline.py:73-74`），命中则 `{duplicate:True}` 不入库、计入 duplicates——这就是 `since='auto'` 重叠窗被消化、无 since 也能 limit 重跑不双写的地方。单条异常不阻塞整批。
- **launchd 链**（`run_x_ingest.sh`）：`set -u` 不 `set -e`，退出码透传。串三步：(1) `x-ingest-daily`（主步，`EXIT_CODE`）；(2) `x-freshness`（`|| true` 吞错）；(3) `daily-report`（`|| true`）。`exit $EXIT_CODE` 只透传主步。日志 append `data/x-ingest.log`。定时见 §2.2 说明（03:00 launchd / 06:45 生产）。
- **`x-freshness`**（`_action_source_freshness_check`，`jobs.py:451`）：按 `events.created_at`（**入库时间**，非 occurred_at）查 `MAX(created_at) WHERE source LIKE 'X ·%'`，超 `max_age_hours`（默认 48）告警并可推飞书、`raise_on_stale`（默认 True）→ job exit 1。补的是 06-07/06-08 的「cron 假活」（ingest exit=0 但 ingested=0 连续两天没人发现）。**监控型 job 必须配高 `max_failures`（如 999），否则报警 3 次自 auto-disable**。
- **`daily-report`**（`_action_daily_report`，`jobs.py:532`）：复用 `weekly_report(window=7)` 出滚动 7 天 markdown，落 `agent/reports/daily-YYYY-MM-DD.md`（同名覆盖），额外按 source `' · '` 第一段聚合「信息源构成」表（565-581），让 X 贡献显式可见。
- **`decide`**（`decision.py:507`）：events 的下游消费者，取数 SQL 在辅助函数 `_fetch_events`（`decision.py:52-64`，`SELECT * FROM events WHERE occurred_at >= ? AND occurred_at < ? ORDER BY occurred_at DESC`，`decide` 在 519-520 调用它）。**不在 x-ingest 链上**，是 decision job / `/decide` API 按需触发的独立消费者，依赖 ISO `occurred_at` 才能正确切窗。

### 8.4 export / backfill / 回写 wiki（均 operator 手动，不在 cron 链）

- **`export_x_events.py::export`**（`export_x_events.py:62`）：复用 `XSqliteSource` 读近 `since_days`（默认 30）→ 按 `config/rss_feeds.json` 的 `shared_keywords` 过滤（与 RSS 同口径，`--no-keyword-filter` 关）→ 脱敏成 5 字段 JSONL（`title/content/source/url/occurred_at`）→ 写 `agent/data-live/x-events-archive.jsonl`（按 `(occurred_at,url)` 升序、tmp+replace 原子落盘，git diff 只增不乱）→ operator git push → GitHub Actions `refresh_live_data` 读它进日报/看板。Actions 永远看不到 `x.sqlite3` 本体（零服务器）。
- **`backfill_x_occurred_at.py::main`**（`backfill_x_occurred_at.py:25`）：一次性回填。扫 `events WHERE source LIKE 'X · %' OR '% · @%'`（44 行）→ 逐条 `_to_iso`（复用 `x_sqlite._to_iso`，22 行），已 ISO 或不可解析跳过，其余 `executemany UPDATE occurred_at`（76 行）。历史 368 条 Twitter 格式需补；新数据已自动 ISO。幂等、`--dry-run` 只统计。必要性：decide 窗口/排序按字符串比 `occurred_at`（注释在 `x_sqlite.py:43`），非 ISO 给错时序。
- **`x_to_wiki.py::run`**（`x_to_wiki.py:91`）：`XSqliteSource` 读近 `since_days`（默认 14）→ 用 `wiki_index` 的实体/人物别名 matcher 做 NER（100-101/117）→ 命中的 slug 收集推文 → 整段替换目标 md 的 `## X 动态` 段（`_upsert_section` @63，原子写 `.tmp.replace`）。整账号零命中 → 起草 `expert/wiki/_drafts/x_new_entities/<account>.md` 供人审（可能是 wiki 缺的新实体，138-148）。确定性/幂等：按 `(occurred_at desc, url)` 稳定排序、每条目 `max_items`。脱敏：只写日期/账号/链接/≤120 字截断正文。

---

## 9. 运维手册要点

- **登录 / 换 cookie**：首次 `python -m x_agent.cli login`（用户名/邮箱/密码 + 可选 TOTP，走 twikit 生成 `data/cookies.json`）。或 `import-cookies <浏览器导出JSON>`（校验 `auth_token+ct0`）。cookie 失效表现为整份 seed 逐个跑成 `unauthorized`——见到即重导 fresh cookie。
- **续跑 / 省 quota**：撞 429 或部分失败后，用 `scrape --only <handle...>` 只抓未成功账号。`--only` 未命中的账号会显式 WARN。
- **429 应对**（`NEXT_STEPS.md:76-89`）：(1) 等 1–2 小时让限流窗口过；(2) `--only` 续跑；(3) 实在卡死重导 cookie。降概率手段：把 `sleep_between_accounts` 抖动从 `[3,7]` 拉到 `[8,15]`（`NEXT_STEPS.md:9`，上轮在第 63 个账号处 429）。
- **定时窗口**：抓取**手动**（单次 30–60 分钟，不进调度链）；ingest 链走 launchd 03:00（本机）/ 生产 cron 06:45（`run_job.sh x-ingest-daily`）。`x-freshness` 断供监控 `max_age_hours=48`，注册时务必 `--max-failures 999`。
- **翻译工作流**：`python scripts/dump_for_translate.py`（连 `x.sqlite3`，去已译、启发式跳过已中文即 CJK 占比 ≥60%，每 250 条一批写 `data/translate_batches/`）→ 人工/子 agent 译进 `data/translate_results/batch_NN_zh.json` → `python scripts/import_translations.py`（标准 json 失败走 `_lenient_parse` 正则容忍未转义引号，`INSERT OR REPLACE`，`engine='claude-code-subagent'`）。
- **轻量桥 push**：可能被机器人 `data-refresh` 抢先占 non-fast-forward，需 `git pull --rebase` 后重推（归档纯追加一般无冲突）；`emitted=0` 多半是关键词全过滤，用 `--no-keyword-filter` 对比。
- **路径**：所有脚本硬编码 macOS 绝对路径 `/Users/john/InvesResearch/expert/X`；`NEXT_STEPS.md:29` 里残留老路径 `/Users/john/lichao/X`，换机器要按真实根目录改。

---

## 10. 已知问题与后续计划

- **X 端点漂移（2026-06-08）**：`UserTweetsAndReplies`（Replies tab）返回 404。`config.example.yaml:23` 默认 `include_replies:false`；`scraper.py:235-241` 已 `try/except NotFound` 兜底（丢该账号 Replies 但 Tweets 照常入库）。后续看 twikit upstream（#408/#417）或切自实现 GraphQL。
- **5 个失败账号**：`stokespace / slingshot_aero / HTVXA / NorwegianSpaceAg / LM_Space` 报 `user_not_found`，多为 handle 停用/改名，需先在 xlsx 更正（`NEXT_STEPS.md:54,97`）。
- **cron 假活**：operator 几天不跑 scrape，ingest 链每天重读同一份 stale `x.sqlite3`，`ingested=0/duplicates=N` 表面 success 实则无增量——`x-freshness` 已把此静默态变显式告警。
- **首页/翻页 429 不对称**：翻页途中 429 被裸 except 吞成静默少抓（`scraper.py:196`），无退避补偿。
- **孤儿媒体风险**：`media` 表无到 `tweets(id)` 外键（`db.py:61`），可能写入对应推文不存在的媒体行。
- **`upsert_account_profile` 是纯 UPDATE**（`db.py:164-212`）：若 username 未先经 `seed_accounts` 建行，回写静默影响 0 行、资料丢失——强依赖「种子先行」的调用顺序。
- **报告不限量**：`TOP_TWEETS_PER_ACCOUNT=999`，大账号会撑爆 HTML（`reports.py:43`）。
- **Tier 3（X → wiki 单向增量回写）**：`x_to_wiki.py` 已具备但未进调度、仍待启动（`NEXT-STEPS.md:501`）。
- **运行栈约束**：`expert/X` 与 `agent` 均 `requires-python>=3.10`（用 `str | Path`、`set[str]` 等泛型注解，虽有 `from __future__ import annotations` 使其字符串化、但 pyproject 显式声明 3.10），**不能被 ECS 宿主 python 3.6 运维脚本直接 import**。

---

## 11. 关键代码索引（速查表）

| 主题 | 位置 | 说明 |
|---|---|---|
| run 顶层编排 | `expert/X/x_agent/scraper.py:275` | `run_scrape`：--only 过滤/重试循环/checkpoint |
| 单账号流程 | `expert/X/x_agent/scraper.py:205` | `scrape_account`，返回 `(new,seen,err)` |
| 429 退避重试 | `expert/X/x_agent/scraper.py:327` | 对所有冒泡的 `TooManyRequests` 生效，最坏 3×900s |
| commit-per-account | `expert/X/x_agent/scraper.py:347` | 每账号 checkpoint，中途 kill 不丢数据 |
| Replies 404 降级 | `expert/X/x_agent/scraper.py:236` | 仅 Replies 套 `except NotFound`，保留 Tweets-only |
| 分页裸 except | `expert/X/x_agent/scraper.py:196` | 翻页 429 被吞成提前截断（与首页不对称） |
| --only 未命中 warn | `expert/X/x_agent/scraper.py:286` | 修静默跳过（已知问题 #1） |
| `in_reply_to_username` 名不副实 | `expert/X/x_agent/scraper.py:156` | 实存 `in_reply_to_user_id_str` |
| twikit 补丁生效点 | `expert/X/x_agent/__init__.py:2` · `_twikit_patch.py:15,89` | KEY_BYTE 双段匹配 / `following_count→friends_count` |
| SQLite schema | `expert/X/x_agent/db.py:11-94` | 5 表 + 3 索引 |
| is_new 判定 | `expert/X/x_agent/db.py:230` | 插入前 `SELECT 1` |
| first_seen_at 保留 | `expert/X/x_agent/db.py:241` | 冲突分支不更新 `first_seen_at/run_id` |
| seed 冲突分支 | `expert/X/x_agent/db.py:119` | 只覆盖 `source_*` 三列 |
| profile 纯 UPDATE + COALESCE | `expert/X/x_agent/db.py:164,183` | user_id 不被 NULL 覆盖 |
| CLI 入口 | `expert/X/x_agent/cli.py:263` | `main`：load_config+resolve_paths+分发 |
| 路径派生 | `expert/X/x_agent/cli.py:33` | 全默认路径在此 |
| cookie 归一 | `expert/X/x_agent/cli.py:72` | 三格式 + `_X_DOMAINS`/`_REQUIRED_COOKIES` |
| xlsx 表头校验 | `expert/X/x_agent/accounts.py:23` | 前 4 列严格匹配中文表头 |
| 报告入口 | `expert/X/x_agent/reports.py:735` | `write_reports` → md/html/(pdf) |
| 唯一读库处 | `expert/X/x_agent/reports.py:118` | `collect`，6 条 SQL |
| Tweet.score | `expert/X/x_agent/reports.py:69` | `view_count or 加权`（量级混排坑） |
| PDF 渲染 | `expert/X/x_agent/reports.py:708,32` | headless Chrome `--print-to-pdf` |
| 翻译 dump / import | `expert/X/scripts/dump_for_translate.py:19` · `import_translations.py:22` | CJK≥60% 跳过 / `_lenient_parse` |
| agent Source | `agent/satellite_agent/sources/x_sqlite.py:136` | `XSqliteSource` |
| 增量 SQL | `agent/satellite_agent/sources/x_sqlite.py:228` | `first_seen_at > since`（NULL 放过） |
| occurred_at 归一 | `agent/satellite_agent/sources/x_sqlite.py:40,310` | `_to_iso(created_at)` |
| 只读打开 | `agent/satellite_agent/sources/x_sqlite.py:186` | `mode=ro&immutable=1` 三段 fallback |
| source 标签 / 9 分类 | `agent/satellite_agent/sources/x_sqlite.py:77,297` | `X · 分类 · @handle`，不绑主线 |
| since=auto 水位 | `agent/satellite_agent/sources/x_sqlite.py:106` | `MAX(occurred_at) WHERE source LIKE 'X ·%' - margin` |
| 5 主线本体 | `agent/satellite_agent/ontology.py:11` | `Thread`：核心网/终端/芯片/运营支撑/运载发射 |
| x-ingest action | `agent/satellite_agent/jobs.py:188,604` | `_action_x_ingest` + `register_action` |
| x-freshness | `agent/satellite_agent/jobs.py:451` | 按 `created_at` 判断上游断供 |
| daily-report | `agent/satellite_agent/jobs.py:532` | 复用 `weekly_report(7)` + 信息源构成表 |
| 逐条入库 + 去重 | `agent/satellite_agent/ingest_pipeline.py:29,74` | `ingest_raw` + `find_event_by_url_and_time` |
| decide 取数 | `agent/satellite_agent/decision.py:52,507` | `_fetch_events` / `decide` |
| launchd 链 | `agent/scripts/run_x_ingest.sh` | x-ingest → x-freshness → daily-report |
| operator scrape | `agent/scripts/run_x_scrape.sh:44` | 透传 `x_agent.cli scrape` |
| 可移植 job 入口 | `agent/scripts/run_job.sh` | 反推仓库根 + 加载 .env |
| 导出桥 | `agent/scripts/export_x_events.py:62` | 脱敏 5 字段 JSONL |
| occurred_at 回填 | `agent/scripts/backfill_x_occurred_at.py:25` | 一次性、幂等 |
| X→wiki 回写 | `agent/scripts/x_to_wiki.py:63,91` | `_upsert_section` / `run` |
| RawEvent 契约 | `agent/satellite_agent/sources/base.py:17` | title/text 必填，其余可选 |
