# 12 · vendor 4 采集工具 → expert/collectors/ + satagent collect 入口

延续 续 10 sources.html §5 把 14 个 public/ skill 分了 4 状态(✅ 已集成 / ⏸ 工具
/ ⏳ 待接入 / 🆕 主代码)后, 用户问 "采集工具放到整体代码的什么位置, 如何合入"。
本轮 vendor 4 个工具 + 写 wrapper + 接 CLI + 单测 + 文档同步, 单 commit 收口。

## 1. 4 工具的特征 (摸底过程)

| 工具                                         | 行数  | 主入口                              | 输入                | 输出                              |
|---------------------------------------------|------:|------------------------------------|---------------------|----------------------------------|
| sec-filing-downloader                       | 908+378 | make_sec_package.py               | --companies 多家一串 | 10-K/10-Q HTML + manifest + zip   |
| collect-investor-meeting-materials          | 672   | meeting_materials_downloader.py    | --company X --ticker Y | earnings/IR/AGM + manifest + zip  |
| international-financial-reports             | 586   | international_financial_reports... | 硬编码 ETL/TSAT/GILT | 年/半/季报 + manifest + zip       |
| collect-customer-intelligence-materials     | 308   | build_customer_materials_package.py | --company X --ticker Y | 招投标 + usaspending awards      |

**共同点**:都 stdlib-only(无 third-party 依赖)/ 都用 `urllib.request` / 都遵循
Codex skill 三件套 (SKILL.md + agents/openai.yaml + scripts/*.py) / 产物结构高度一致
({TICKER}/<category>/ + manifest.csv + download_log.csv + zip)。

**差异**:接口签名不同 (sec 多家一串 / investor+customer 单家 / intl 硬编码) →
wrapper 内部 dispatch 拉平给用户一个统一接口。

## 2. 放哪 (E 方案: expert/collectors/ + wrapper)

```
public/HarnessFarm-skills/sec-filing-downloader/            → expert/collectors/sec/
public/HarnessFarm-skills/collect-investor-meeting-materials/ → expert/collectors/investor/
public/HarnessFarm-skills/international-financial-reports/   → expert/collectors/intl-reports/
public/HarnessFarm-skills/collect-customer-intelligence-materials/ → expert/collectors/customer/
```

**为什么 expert/collectors/**:
- `expert/` 已是"agent 上游所有东西" (跟 expert/X 抓推文 / expert/wiki 知识库种子 同级)
- 不污染 `agent/` runtime 命名空间 (agent/satellite_agent/ 是 runtime / agent/scripts/ 是 ops)
- 不放 repo 根 (避免 top-level 膨胀)
- 不留 `public/` (是 .gitignored, 团队成员看不到)

## 3. 怎么合入 (4 步)

### Step 1 — vendor

```bash
cp -r public/HarnessFarm-skills/sec-filing-downloader  expert/collectors/sec
cp -r public/HarnessFarm-skills/collect-investor-meeting-materials  expert/collectors/investor
cp -r public/HarnessFarm-skills/international-financial-reports  expert/collectors/intl-reports
cp -r public/HarnessFarm-skills/collect-customer-intelligence-materials  expert/collectors/customer
find expert/collectors -name ".DS_Store" -delete
```

保留 `SKILL.md` (文档) + `scripts/*.py` (工具) + `agents/openai.yaml` (Codex reference)。

### Step 2 — wrapper `agent/scripts/run_collector.py` (266 行)

统一接口:
```bash
python3 agent/scripts/run_collector.py sec  --companies "RKLB Rocket Lab, PL Planet Labs"
python3 agent/scripts/run_collector.py investor --companies "RKLB Rocket Lab"
python3 agent/scripts/run_collector.py intl-reports --discover-only
python3 agent/scripts/run_collector.py customer --companies "VOYG Voyager Technologies"
```

设计要点:
- **subprocess 调原脚本** — 不改 vendor 来的脚本, 一切由 wrapper 拼参数
- **uniform `--companies`** — 工具签名各异, wrapper dispatch
- **--dry-run 默认关** — 真跑 subprocess; --dry-run 时只打印 cmd 不真起
- **产物落 `agent/data/collected/<kind>/<run-ts>/`** — 统一位置, 下轮 InvestorManifestSource 默认从这里读
- **meta.json 每跑必写** — 记录 cmd / cwd / exit code / stdout_tail / stderr_tail / dry_run / companies_input
- **失败不阻塞批** — investor/customer 逐家迭代时单家失败 → 写 skipped/error, 继续下一家

### Step 3 — CLI 入口 `satagent collect`

`agent/satellite_agent/cli.py` 加 `cmd_collect` (薄壳, importlib 装 run_collector 当 module 跑):

```bash
satagent collect sec --companies "RKLB Rocket Lab, PL Planet Labs"
satagent collect investor --companies "RKLB Rocket Lab" --years-back 5
satagent collect intl-reports --discover-only
satagent collect customer --companies "VOYG Voyager Technologies"
satagent collect sec --companies "RKLB" --dry-run
```

### Step 4 — 单测 `tests/test_run_collector.py` (11 个)

不真起 subprocess (那是 IO 测试), 验:
- `_parse_companies` 解析 4 种格式 (full / ticker-only / multiline / empty / strip+upper)
- 4 个 subcommand argparse 都 routable
- dry-run 写 meta.json 但不真跑 subprocess
- sec 缺 --companies → exit 2
- intl-reports 不需要 --companies, 硬编码 ETL/TSAT/GILT 写进 meta
- customer/investor 缺 name → skipped 该家继续别家
- 4 vendor 进的 entry script 都存在

**全过 11/11, 全仓 350 → 361 passed / 0 真实回归**。

## 4. Smoke 实测

**SEC 真跑** `satagent collect sec --companies "RKLB Rocket Lab"`:
- exit=0, 输出 `agent/data/collected/sec/20260609T070747Z/`
- 产物 `SEC_Filings_Downloader.zip` 12KB + `meta.json`
- zip 内含 `sec_filing_downloader.py` (35KB 原脚本) + `manifest/SEC_filing_manifest.csv` (空 header) + `download_log.csv`
- (SEC make_sec_package.py 的设计:它产 *downloader 包*, 不真下 filings; 真下需 operator 在 mac 跑 zip 里的 `run_on_mac.command`)

**4 kind dry-run** 都过, meta.json 正确写。

## 5. 采集 → 入库管线即将闭环

下一轮路线 A 写 `sources/investor_manifest.py` 第 7 个 Source, 默认 path 指向
`agent/data/collected/<kind>/<latest-run-ts>/`, 一行 ingest:

```bash
satagent collect investor --companies "VOYG Voyager Technologies"   # 产 manifest
satagent fetch --source investor-manifest                              # 入 events
```

5 个 *_manifest.csv 共 916 行 (CRS 100 / MDA 192 / VOYG-inv 63 / VOYG-cust 296 /
mda-voyg-crs 165), 预计 events +640~+916, 1 天工作量。

## 6. 后续接入点 (本 commit 不做)

- **路线 A InvestorManifestSource** — 1 天 · 下一轮最自然单 commit
- **路线 B LLM 抽取走 expert-wiki-ingest** — 3-5 天 · MDA/VOYG/CRS PDF/XLSX 入 wiki
- **路线 C 不接入 3 个低 ROI** — shock 原素材 / HAWK-LUNR / SpaceX briefing

## 7. 经验

- **vendor 而不是 submodule** — 4 工具是 standalone CLI, 无 third-party 依赖, vendor 进来天然可用; submodule 会拖 git 复杂度而无收益
- **wrapper 而不是改原脚本** — 工具签名各异, 改原脚本会跟上游分叉; wrapper 内部 dispatch 把差异封掉, 上游 (HarnessFarm) 更新时 cp 一次即可
- **统一产物位置** — `agent/data/collected/<kind>/<run-ts>/` 是契约位置, 让下游 Source 不用问 path; 1 个目录 1 个 source 是单一职责
- **meta.json 每跑必写** — 不只是 logging, 是 reproducibility (cmd + cwd + exit + tails 都在), 出问题反查 5 秒搞定
- **subprocess.run timeout=900s** — vendor 工具有的会卡 (SEC EDGAR rate limit / 网络), 必须有 timeout 兜底; 单 wrapper 兜每跑 15 分钟上限
- **importlib 装 run_collector** — CLI 把 wrapper 当 module 跑 (不 subprocess) 让退出码直接透传 + 单测可 monkeypatch DEFAULT_OUTPUT_ROOT
- **冲突路径处理** — `out_dir.relative_to(REPO_ROOT)` 单测时会炸 (tmp_path 不在 REPO_ROOT), 用 try/except 容忍非仓库内目录
