# 爬虫逻辑梳理与数据校验

## 一、两条入口，数据流不同

### 1. 入口 A：`npm run crawler`（main.py）

- **流程**：RSS 抓取 → 关键词过滤 → 分类/严重度 → **直接写 situation_update** → 通知 API
- **不经过**：翻译、news_content、AI 提取（战损/基地等）
- **写入表**：`situation_update`、`situation.updated_at`
- **用途**：轻量、只给「事件脉络」喂新条目，不更新战损/基地/报复指数

```
RSS_FEEDS → fetch_all() → KEYWORDS 过滤 → parser_ai.classify_and_severity
    → write_updates(items) → situation_update INSERT + situation 表 touch
    → notify_api()
```

### 2. 入口 B：`npm run gdelt`（realtime_conflict_service.py）

- **流程**：RSS 抓取 → 翻译 → 清洗 → **news_content 去重** → situation_update → **AI 提取 → db_merge** → GDELT 事件（可选）→ 通知 API
- **写入表**：`news_content`、`situation_update`、`situation`；提取后还有 `combat_losses`、`key_location`、`retaliation_*`、`wall_street_trend` 等
- **用途**：完整管线，前端「战损 / 军事基地 / 报复 / 美股」等数据都依赖这条

```
RSS → fetch_all() → translate_to_chinese → cleaner_ai → save_and_dedup → news_content
    → write_updates(new_items) → situation_update
    → _extract_and_merge_panel_data(new_items) → extract_from_news() → db_merge.merge()
    → (可选) fetch_gdelt_events() → gdelt_events, conflict_stats
    → _notify_node()
```

**结论**：要检查「抓回的数据是否有效」且包含战损/基地等，应跑 **入口 B**（gdelt 服务）；若只关心事件脉络条数，可看入口 A。

---

## 二、入口 B 逐步拆解（用于逐段校验）

### 2.1 RSS 抓取与过滤

| 步骤 | 位置 | 说明 |
|------|------|------|
| 源列表 | `config.RSS_FEEDS` | 多国媒体 RSS，见 config.py |
| 抓取 | `scrapers.rss_scraper.fetch_all()` | feedparser，单源超时 10s |
| 过滤 | `_matches_keywords(text)` | 标题+摘要 至少命中 `config.KEYWORDS` 中一个才保留 |
| 去重 | `(title[:80], link)` | 同一条不重复加入当次列表 |
| 分类 | `parser_ai.classify_and_severity(text)` | 得到 category、severity（Ollama 或规则） |

**校验**：`npm run crawler:test` 看本次抓到的条数；若为 0，查网络或放宽/检查 KEYWORDS。

### 2.2 翻译与清洗（仅入口 B）

| 步骤 | 位置 | 说明 |
|------|------|------|
| 翻译 | `translate_utils.translate_to_chinese()` | 标题/摘要译成中文（依赖配置） |
| 清洗 | `cleaner_ai.clean_news_for_panel()` | 截断、清理；`ensure_category` / `ensure_severity` 合法化 |

### 2.3 落库：news_content（去重）与 situation_update

| 步骤 | 位置 | 说明 |
|------|------|------|
| 去重 | `news_storage.save_and_dedup(items)` | 按 `content_hash(title, summary, url)` 判重，只插入新记录 |
| 表 | `news_content` | id, content_hash, title, summary, url, source, published_at, category, severity |
| 表 | `situation_update` | 仅对 **去重后的 new_items** 调用 `write_updates()`，供前端「事件脉络」 |

**校验**：

- `news_content`：`SELECT COUNT(*), MAX(published_at) FROM news_content`
- `situation_update`：`SELECT COUNT(*), MAX(timestamp) FROM situation_update`
- 服务状态：`GET http://localhost:8000/crawler/status` 看 `last_fetch_items` / `last_fetch_inserted` / `last_fetch_error`

### 2.4 AI 提取与 db_merge（战损 / 基地 / 报复等）

| 步骤 | 位置 | 说明 |
|------|------|------|
| 输入 | `_extract_and_merge_panel_data(new_items)` | 仅处理本次 **新增** 的 new_items，前 limit 条（DashScope 10 条，规则 25 条，Ollama 10 条） |
| 文本 | 每条 `title + " " + summary`，长度 &lt; 20 跳过 |
| 提取器选择 | 环境变量 | `DASHSCOPE_API_KEY` → extractor_dashscope；`CLEANER_AI_DISABLED=1` → extractor_rules；否则 extractor_ai（Ollama） |
| 输出结构 | 见 panel_schema / 各 extractor | `situation_update?`, `combat_losses_delta?`, `retaliation?`, `wall_street?`, `key_location_updates?` |
| 合并 | `db_merge.merge(extracted)` | 见下表 |

**merge 映射概要**：

| 提取字段 | 写入表/逻辑 |
|----------|-------------|
| situation_update | situation_update 表 INSERT（id 为 hash） |
| combat_losses_delta | combat_losses 表，按 side 增量叠加 |
| retaliation | retaliation_current 替换 + retaliation_history 追加 |
| wall_street | wall_street_trend 表 INSERT |
| key_location_updates | key_location 表 UPDATE status/damage_level（name LIKE 关键词） |

**校验**：

- 战损：`SELECT * FROM combat_losses`
- 基地：`SELECT id, name, side, status, damage_level FROM key_location WHERE status != 'operational' OR damage_level > 0`
- 报复：`SELECT * FROM retaliation_current` 与 `retaliation_history` 最近几条
- 事件脉络：`SELECT id, timestamp, category, summary, severity FROM situation_update ORDER BY timestamp DESC LIMIT 20`

### 2.5 GDELT（可选）

- `GDELT_DISABLED=1` 时跳过 GDELT，仅用 RSS；可用 `_rss_to_gdelt_fallback()` 用 RSS 标题生成 gdelt_events。
- 未禁用时：`fetch_gdelt_events()` 拉 GDELT → 写 `gdelt_events`、`conflict_stats`。

**校验**：`SELECT COUNT(*), MAX(event_time) FROM gdelt_events`；`SELECT * FROM conflict_stats WHERE id=1`。

---

## 三、如何检查「抓回的数据是否有效」

1. **确认跑的入口**  
   - 只跑 `npm run crawler`：只有 situation_update 会有新数据，战损/基地不会变。  
   - 跑 `npm run gdelt` 且服务常驻：才会既有 situation_update，又有 combat_losses、key_location 等。

2. **看 DB 与 API**  
   - 同上：查 `news_content`、`situation_update`、`combat_losses`、`key_location`、`retaliation_*`、`gdelt_events`、`conflict_stats`。  
   - 前端数据来源：`GET /api/situation`（见 server/situationData.js），对照上述表即可。

3. **看提取是否触发**  
   - 若 `combat_losses` / `key_location` 一直不更新：确认是入口 B、有 new_items、提取器未报错；可对单条新闻跑 `extract_from_news(text)` 看是否产出 combat_losses_delta / key_location_updates。

4. **重跑历史提取（补数据）**  
   - `POST http://localhost:8000/crawler/backfill`：用当前 situation_update 最近 50 条重新做一次提取并 merge，可用来修历史未提取的数据。

---

## 四、配置与环境变量（与数据有效性相关）

| 变量 | 作用 |
|------|------|
| DB_PATH | 与 server 共用的 SQLite 路径，必须一致 |
| API_BASE | 通知 Node 的地址，merge 后通知前端 |
| DASHSCOPE_API_KEY | 有则用 DashScope 提取；无则用 Ollama 或规则 |
| CLEANER_AI_DISABLED=1 | 用规则提取（extractor_rules），不用 Ollama |
| GDELT_DISABLED=1 | 不用 GDELT，仅 RSS；RSS 可转 gdelt_events 占位 |
| CRAWL_INTERVAL | main.py 抓取间隔（秒） |
| RSS_INTERVAL_SEC / FETCH_INTERVAL_SEC | realtime 服务里 RSS / GDELT 间隔 |

按上述顺序对照「入口 → RSS → 去重 → situation_update → 提取 → merge → 表」即可逐段检查爬虫抓回的数据是否有效。
