# GDELT 实时冲突服务 + 新闻爬虫

## 数据来源梳理

### 1. GDELT Project (gdelt_events)

| 项目 | 说明 |
|------|------|
| API | `https://api.gdeltproject.org/api/v2/doc/doc` |
| 查询 | `query=United States Iran military`（可配 `GDELT_QUERY`） |
| 模式 | `mode=ArtList`，`format=json`，`maxrecords=30` |
| 时间范围 | **未指定时默认最近 3 个月**，按相关性排序，易返回较旧文章 |
| 更新频率 | GDELT 约 15 分钟级，爬虫 60 秒拉一次 |

**数据偏老原因**：未传 `timespan` 和 `sort=datedesc`，API 返回 3 个月内“最相关”文章，不保证最新。

### 2. RSS 新闻 → 看板实时数据（主输出）+ 事件脉络

| 项目 | 说明 |
|------|------|
| **主输出** | **看板实时数据**：战损（combat_losses）、据点状态（key_location）、冲突事件（gdelt_events）、统计（conflict_stats）等，供前端战损/基地/地图等面板展示。 |
| 辅助输出 | 事件脉络（situation_update）：时间线摘要，非主展示目标。 |
| 源 | 多国主流媒体：美/英/法/俄/中/伊/卡塔尔等（见 `config.RSS_FEEDS`） |
| 过滤 | 标题/摘要需含 `KEYWORDS` 之一（iran、usa、strike、military 等） |
| 更新 | 爬虫按 `RSS_INTERVAL_SEC` 拉取；每 `BACKFILL_CYCLES` 轮会从近期事件回填一次战损/据点，保证面板数据与最新内容一致。 |

**GDELT 无法访问时**：设置 `GDELT_DISABLED=1`，仅用 RSS；部分境外源可能需代理。

### 3. AI 新闻清洗与分类（可选）

- **清洗**：`cleaner_ai.py` 用 Ollama 提炼新闻为简洁摘要，供面板展示
- **分类**：`parser_ai.py` 用 Ollama 替代规则做 category/severity 判定
- 需先安装并运行 Ollama：`ollama run llama3.1`
- 环境变量：`OLLAMA_MODEL=llama3.1`、`PARSER_AI_DISABLED=1`、`CLEANER_AI_DISABLED=1`（禁用对应 AI）

---

**看板实时数据更新**：爬虫抓取 → 提取战损/据点等 → 写入 combat_losses、key_location 等 → 调用 Node 通知 → WebSocket 广播 → 前端战损/基地/地图等面板刷新。事件脉络（时间线）为同一流水线的辅助输出。

## 依赖

- **Python 3.11+**（推荐 3.11 或 3.12）

```bash
pip install -r requirements.txt
```

或使用 pyproject：`pip install -e crawler/`（在项目根目录）。

- `deep-translator`：GDELT 与 RSS 新闻入库前自动翻译为中文。
- `dashscope`：可选，配置 `DASHSCOPE_API_KEY` 后启用通义提取/清洗。

## 运行（需同时启动 3 个服务）

| 终端 | 命令 | 说明 |
|------|------|------|
| 1 | `npm run api` | Node API + WebSocket（必须） |
| 2 | `npm run gdelt` | GDELT + RSS 爬虫（**事件脉络数据来源**） |
| 3 | `npm run dev` | 前端开发 |

**事件脉络不更新时**：多半是未启动 `npm run gdelt`。只跑 `npm run api` 时，事件脉络会显示空或仅有缓存。

## 如何检查爬虫是否工作正常

按下面顺序做即可确认整条链路（爬虫 → 数据库 → Node 重载 → API/WebSocket）正常。

### 1. 一键验证（推荐）

先启动 API，再执行验证脚本（可选是否顺带启动爬虫）：

```bash
# 终端 1：必须
npm run api

# 终端 2：执行验证（不启动爬虫，只检查当前状态）
./scripts/verify-pipeline.sh

# 或：顺带启动爬虫并等首次抓取后再验证
./scripts/verify-pipeline.sh --start-crawler
```

脚本会检查：API 健康、态势数据含 `lastUpdated`、爬虫服务是否可达、`news_content`/situation_update、战损字段、`POST /api/crawler/notify` 是否可用。

### 2. 手动快速检查

| 步骤 | 命令 / 操作 | 正常表现 |
|-----|-------------|----------|
| API 是否在跑 | `curl -s http://localhost:3001/api/health` | 返回 `{"ok":true}` |
| 态势是否可读 | `curl -s http://localhost:3001/api/situation \| head -c 300` | 含 `lastUpdated`、`usForces`、`recentUpdates` |
| RSS 能否抓到 | `npm run crawler:test` | 输出「RSS 抓取: N 条」，N>0 表示有命中 |
| 爬虫服务（gdelt） | `curl -s http://localhost:8000/crawler/status` | 返回 JSON，含 `db_path`/`db_exists` 等 |
| 库里有无爬虫数据 | `sqlite3 server/data.db "SELECT COUNT(*) FROM situation_update; SELECT COUNT(*) FROM news_content;"` 或访问 `http://localhost:3001/api/db/dashboard` | situation_update、news_content 条数 > 0（跑过流水线后） |
| 通知后是否重载 | 爬虫写库后会 POST `/api/crawler/notify`，Node 会 `reloadFromFile` 再广播 | 前端/`/api/situation` 的 `lastUpdated` 和内容会更新 |

### 3. 跑一轮流水线（不常驻爬虫时）

不启动 gdelt 时，可单次跑完整流水线（抓取 → 去重 → 写表 → notify）：

```bash
npm run api   # 保持运行
cd crawler && python3 -c "
from pipeline import run_full_pipeline
from config import DB_PATH, API_BASE
n_fetched, n_news, n_panel = run_full_pipeline(db_path=DB_PATH, api_base=API_BASE, notify=True)
print('抓取:', n_fetched, '去重新增:', n_news, '面板写入:', n_panel)
"
```

有网络且有关键词命中时，应看到非零数字；再查 `curl -s http://localhost:3001/api/situation` 或前端事件脉络是否出现新数据。

**按时间范围测试（例如 2 月 28 日 0 时至今）**：RSS 流水线支持只保留指定起始时间之后的条目，便于测试「从某日 0 点到现在」的数据。

```bash
# 默认从 2026-02-28 0:00 到现在
npm run crawler:once:range

# 或指定起始时间
./scripts/run-crawler-range.sh 2026-02-28T00:00:00
```

需设置环境变量 `CRAWL_START_DATE`（ISO 时间，如 `2026-02-28T00:00:00`）。GDELT 时间范围在启动 gdelt 服务时设置，例如：`GDELT_TIMESPAN=3d npm run gdelt`（最近 3 天）。

### 4. 仅测提取逻辑（不写库）

```bash
npm run crawler:test:extraction   # 规则/db_merge 测试
# 或按 README「快速自测命令」用示例文本调 extract_from_news 看 combat_losses_delta / key_location_updates
```

**常见现象**：抓取 0 条 → 网络/RSS 被墙或关键词未命中；situation_update 为空 → 未跑流水线或去重后无新增；前端不刷新 → 未开 `npm run api` 或未开爬虫（gdelt）。

### 5. 爬虫与面板是否联通

专门检查「爬虫写库」与「面板展示」是否一致：

```bash
./scripts/check-crawler-panel-connectivity.sh
```

会对比：爬虫侧的 `situation_update` 条数 vs 面板 API 返回的 `recentUpdates` 条数，并说明为何战损/基地等不一定随每条新闻变化。

## 爬虫与面板数据联动说明

| 面板展示 | 数据来源（表/接口） | 是否由爬虫更新 | 说明 |
|----------|---------------------|----------------|------|
| **事件脉络** (recentUpdates) | situation_update → getSituation() | ✅ 是 | 每条去重后的新闻会写入 situation_update，Node 收到 notify 后重载 DB 再广播 |
| **地图冲突点** (conflictEvents) | gdelt_events 或 RSS→gdelt 回填 | ✅ 是 | GDELT 或 GDELT 禁用时由 situation_update 同步到 gdelt_events |
| **战损/装备毁伤** (combatLosses) | combat_losses | ⚠️ 有条件 | 仅当 AI/规则从新闻中提取到数字（如「2 名美军死亡」）时，merge 才写入增量 |
| **基地/地点状态** (keyLocations) | key_location | ⚠️ 有条件 | 仅当提取到 key_location_updates（如某基地遭袭）时更新 |
| **地图打击/攻击动画** (mapData.strikeSources, strikeLines) | map_strike_source, map_strike_line | ⚠️ 有条件 | 仅当提取到 map_strike_sources / map_strike_lines 时写入；格式见下「地图打击数据」 |
| **力量摘要/指数/资产** (summary, powerIndex, assets) | force_summary, power_index, force_asset | ❌ 否 | 仅 seed 初始化，爬虫不写 |
| **华尔街/报复情绪** (wallStreet, retaliation) | wall_street_trend, retaliation_* | ⚠️ 有条件 | 仅当提取器输出对应字段时更新 |

因此：**新闻很多、但战损/基地数字不动**是正常现象——多数标题不含可解析的伤亡/基地数字，只有事件脉络（recentUpdates）和地图冲突点会随每条新闻增加。若**事件脉络也不更新**，请确认 Node 终端在爬虫每轮抓取后是否出现 `[crawler/notify] DB 已重载`；若无，检查爬虫的 `API_BASE` 是否指向当前 API（默认 `http://localhost:3001`）。

## 写库流水线（与 server/README 第五节一致）

RSS 与主入口均走统一流水线 `pipeline.run_full_pipeline`：

1. **抓取** → 2. **AI 清洗**（标题/摘要/分类）→ 3. **去重**（news_content.content_hash）→ 4. **映射到前端库字段**（situation_update、combat_losses、key_location 等）→ 5. **更新表** → 6. **有新增时 POST /api/crawler/notify**

- `npm run crawler`（main.py）与 `npm run gdelt`（realtime_conflict_service）的 RSS 分支都调用该流水线。
- 实现见 `crawler/pipeline.py`。

## 数据流

```
GDELT API → 抓取(60s) → SQLite (gdelt_events, conflict_stats) → POST /api/crawler/notify
RSS → 抓取 → 清洗 → 去重 → 写 news_content / situation_update / 战损等 → POST /api/crawler/notify
                                                                    ↓
                                              Node 更新 situation.updated_at + WebSocket 广播
                                                                    ↓
                                                              前端实时展示
```

## 配置

环境变量：

- `DB_PATH`: SQLite 路径，默认 `../server/data.db`
- `API_BASE`: Node API 地址，默认 `http://localhost:3001`
- **`DASHSCOPE_API_KEY`**：阿里云通义（DashScope）API Key。**设置后全程使用商业模型，无需本机安装 Ollama**（适合 Mac 版本较低无法跑 Ollama 的情况）。获取： [阿里云百炼 / DashScope](https://dashscope.console.aliyun.com/) → 创建 API-KEY，复制到环境变量或项目根目录 `.env` 中 `DASHSCOPE_API_KEY=sk-xxx`。摘要、分类、战损/基地提取均走通义。
- `GDELT_QUERY`: 搜索关键词，默认 `United States Iran military`
- `GDELT_MAX_RECORDS`: 最大条数，默认 30
- `GDELT_TIMESPAN`: 时间范围，`1h` / `1d` / `1week`，默认 `1d`（近日资讯）
- `GDELT_DISABLED`: 设为 `1` 则跳过 GDELT，仅用 RSS 新闻（GDELT 无法访问时用）
- `FETCH_INTERVAL_SEC`: GDELT 抓取间隔（秒），默认 60
- `RSS_INTERVAL_SEC`: RSS 抓取间隔（秒），默认 45（优先保证事件脉络）
- `OLLAMA_MODEL`: AI 分类模型，默认 `llama3.1`
- `PARSER_AI_DISABLED`: 设为 `1` 则禁用 AI 分类，仅用规则
- `CLEANER_AI_DISABLED`: 设为 `1` 则禁用 AI 清洗，仅用规则截断
- `FETCH_FULL_ARTICLE`: 设为 `0` 则不再抓取正文，仅用标题+摘要做 AI 提取（默认 `1` 抓取正文）
- `ARTICLE_FETCH_LIMIT`: 每轮为多少条新资讯抓取正文，默认 10
- `ARTICLE_FETCH_TIMEOUT`: 单篇正文请求超时（秒），默认 12
- `ARTICLE_MAX_BODY_CHARS`: 正文最大字符数，默认 6000
- `EXTRACT_TEXT_MAX_LEN`: 送入 AI 提取的原文最大长度，默认 4000

**增量与地点**：战损一律按**增量**处理——AI 只填本则报道的「本次/此次」新增数，不填累计总数；合并时与库内当前值叠加。双方攻击地点通过 `key_location_updates` 更新（美军基地被打击 side=us，伊朗设施被打击 side=iran），会写入 `key_location` 的 status/damage_level。

---

## 主要新闻资讯来源（RSS）

配置在 `crawler/config.py` 的 `RSS_FEEDS`，当前包含：

| 来源 | URL / 说明 |
|------|------------|
| **美国** | Reuters Top News、NYT World |
| **英国** | BBC World、BBC Middle East、The Guardian World |
| **法国** | France 24 |
| **德国** | DW World |
| **俄罗斯** | TASS、RT |
| **中国** | Xinhua World、CGTN World |
| **凤凰** | 凤凰军事、凤凰国际（feedx.net 镜像） |
| **伊朗** | Press TV |
| **卡塔尔/中东** | Al Jazeera All、Al Jazeera Middle East |

单源超时由 `FEED_TIMEOUT`（默认 12 秒）控制；某源失败不影响其他源。

**过滤**：每条条目的标题+摘要必须命中 `config.KEYWORDS` 中至少一个关键词才会进入流水线（伊朗/美国/中东/军事/基地/霍尔木兹等，见 `config.KEYWORDS`）。

### 境内可访问情况（仅供参考，以实际网络为准）

| 通常境内可直接访问 | 说明 |
|-------------------|------|
| **新华网** `english.news.cn/rss/world.xml` | 中国官方外文社 |
| **CGTN** `cgtn.com/rss/world` | 中国国际台 |
| **凤凰** `feedx.net/rss/ifengmil.xml`、`ifengworld.xml` | 第三方 RSS 镜像，中文军事/国际 |
| **人民网** `people.com.cn/rss/military.xml`、`world.xml` | 军事、国际 |
| **新浪** `rss.sina.com.cn` 军事/新闻 | 新浪军事、新浪新闻滚动 |
| **中国日报** `chinadaily.com.cn/rss/world_rss.xml` | 国际新闻 |
| **中国军网** `english.chinamil.com.cn/rss.xml` | 解放军报英文 |
| **俄通社 TASS** `tass.com/rss/v2.xml` | 俄罗斯官媒 |
| **RT** `rt.com/rss/` | 俄罗斯今日俄罗斯 |
| **DW** `rss.dw.com/xml/rss-en-world` | 德国之声，部分地区/时段可访问 |

**境内常需代理**：Reuters、NYT、BBC、Guardian、France 24、Al Jazeera、Press TV 等境外主站 RSS，直连易超时或被墙。境内部署建议：设 `CRAWLER_USE_PROXY=1` 并配置代理，或仅保留上表源（可在 `config.py` 中注释掉不可达的 URL，减少超时等待）。

**国内其他媒体（今日头条、网易、腾讯、新浪微博等）**：今日头条、腾讯新闻、新浪微博等多为 App/信息流产品，**无官方公开 RSS**。如需接入可考虑：第三方 RSS 聚合（如 FeedX、RSSHub 等若有对应频道）、或平台开放 API（若有且合规使用）。当前爬虫已加入新浪（rss.sina.com.cn）、人民网、中国日报、中国军网等有明确 RSS 的境内源；网易新闻曾有 RSS 中心页，具体栏目 XML 需在其订阅页查找后加入 `config.py`。

---

## 为什么爬虫一直抓不到有效信息（0 条）

常见原因与应对如下。

| 原因 | 说明 | 建议 |
|------|------|------|
| **RSS 源在国内不可达** | 多数源为境外站（Reuters、BBC、NYT、Guardian、France24、DW、TASS、RT、Al Jazeera、Press TV 等），国内直连易超时或被墙。 | 使用代理：设 `CRAWLER_USE_PROXY=1` 并配置系统/环境 HTTP(S) 代理，或部署到海外服务器再跑爬虫。 |
| **关键词无一命中** | 只有标题或摘要里包含 `KEYWORDS` 中至少一个词才会保留（如 iran、usa、middle east、strike、基地 等）。若当前头条都不涉及美伊/中东，整轮会 0 条。 | 先跑 `npm run crawler:test` 看是否 0 条；若长期为 0 且网络正常，可在 `config.py` 中适当放宽或增加 `KEYWORDS`（如增加通用词做测试）。 |
| **单源超时导致整轮无结果** | 若所有源都在 `FEED_TIMEOUT` 内未返回，则每源返回空列表，汇总仍为 0 条。 | 增大 `FEED_TIMEOUT`（如 20）；或先单独用浏览器/curl 测某条 RSS URL 是否可访问；国内建议代理后再试。 |
| **分类/清洗依赖 AI 且失败** | 每条命中关键词的条目会调 `classify_and_severity`（Ollama 或 DashScope）。若本机未起 Ollama、未设 DashScope，且规则兜底异常，可能影响该条。 | 设 `PARSER_AI_DISABLED=1` 使用纯规则分类，避免依赖 Ollama/DashScope；或配置好 `DASHSCOPE_API_KEY` / 本地 Ollama 再跑。 |
| **去重后无新增** | 抓到的条数 >0，但经 `news_content` 的 content_hash 去重后「新增」为 0，则不会写 `situation_update`，事件脉络不增加。 | 属正常：同一批新闻再次抓取不会重复写入。等有新头条命中关键词后才会出现新条目。 |

**快速自检**：

```bash
npm run crawler:test
```

输出「RSS 抓取: N 条」。若始终为 0，优先检查网络/代理与 `KEYWORDS`；若 N>0 但面板无新事件，多为去重后无新增或未调 `POST /api/crawler/notify`。

---

## 数据流与 AI 自检

**完整链路**：RSS 抓取 → 关键词过滤 → 翻译/清洗 → 去重（news_content）→ 写 situation_update → 正文抓取（可选）→ **AI 提取**（战损/基地等）→ db_merge 写 combat_losses/key_location 等 → POST /api/crawler/notify → Node 重载并广播。

| 环节 | 说明 | 自检 |
|------|------|------|
| 抓取 | `scrapers/rss_scraper.fetch_all()`，按 KEYWORDS 过滤 | `npm run crawler:test` 看条数 |
| 去重 | `news_storage.save_and_dedup()`，content_hash 落库 news_content | 查 `news_content` 表条数 |
| 事件脉络 | `db_writer.write_updates()` 写 situation_update（与 pipeline 使用同一 db_path） | 查 `situation_update` 表 |
| AI 提取 | 战损/基地等：**有 DASHSCOPE_API_KEY 用通义**，**否则 CLEANER_AI_DISABLED=1 用规则**，否则用 **Ollama**（extractor_ai） | 见下 |
| 分类/严重度 | 每条 RSS 的 category/severity：**PARSER_AI_DISABLED=1 用规则**，否则 DashScope 或 Ollama | 无 AI 时设 `PARSER_AI_DISABLED=1` 可正常跑 |

**如何保证「面板实时数据」有更新**（战损、据点等）：

- **推荐**：设 `CLEANER_AI_DISABLED=1` → 使用 `extractor_rules`（纯规则），无需 Ollama/通义，即可从新闻中提取战损/基地并写入 combat_losses、key_location。  
- 或设 `DASHSCOPE_API_KEY` → 用通义做更细的提取。  
- 否则用 `extractor_ai`（需本机 `ollama run llama3.1`），未起则提取静默失败、面板数字不更新。  
- 服务会每 `BACKFILL_CYCLES` 轮（默认 2 轮）从近期事件再跑一次提取并合并，保证战损/据点与最新内容一致。

**常见 bug 与修复**：

- **事件脉络有、战损/基地不更新**：多为 AI 未跑通（Ollama 未起且未设 DashScope、未设 CLEANER_AI_DISABLED）。可设 `CLEANER_AI_DISABLED=1` 用规则提取，或起 Ollama / 配置 DashScope。
- **多 DB 路径不一致**：pipeline 已统一 `db_path`，`write_updates`、`save_and_dedup`、`merge` 均使用同一 path（或 `config.DB_PATH`）。

---

## 优化后验证效果示例

以下为「正文抓取 + AI 精确提取 + 增量与地点更新」优化后，单条新闻从输入到前端展示的完整示例，便于对照验证。

### 1. 示例输入（新闻摘要/全文片段）

```
伊朗向伊拉克阿萨德空军基地发射 12 枚弹道导弹，造成此次袭击中 2 名美军人员死亡、14 人受伤，
另有 1 架战机在跑道受损。乌代德基地未遭直接命中。同日以色列对伊朗伊斯法罕一处设施发动打击。
```

### 2. AI 提取输出（增量 + 攻击地点）

```json
{
  "summary": "伊朗导弹袭击伊拉克阿萨德基地致美军 2 死 14 伤，1 架战机受损；以军打击伊斯法罕。",
  "category": "alert",
  "severity": "high",
  "us_personnel_killed": 2,
  "us_personnel_wounded": 14,
  "us_aircraft": 1,
  "us_bases_damaged": 1,
  "key_location_updates": [
    { "name_keywords": "阿萨德|asad|al-asad", "side": "us", "status": "attacked", "damage_level": 2 },
    { "name_keywords": "伊斯法罕|isfahan", "side": "iran", "status": "attacked", "damage_level": 1 }
  ]
}
```

说明：战损为**本则报道的新增数**（此次 2 死、14 伤、1 架战机），不是累计总数；地点为双方遭袭设施（美军基地 side=us，伊朗设施 side=iran）。

### 3. 合并后数据库变化

| 表/字段 | 合并前 | 本则增量 | 合并后 |
|--------|--------|----------|--------|
| combat_losses.us.personnel_killed | 127 | +2 | 129 |
| combat_losses.us.personnel_wounded | 384 | +14 | 398 |
| combat_losses.us.aircraft | 2 | +1 | 3 |
| combat_losses.us.bases_damaged | 27 | +1 | 28 |
| key_location（name 含「阿萨德」） | status=operational | — | status=attacked, damage_level=2 |
| key_location（name 含「伊斯法罕」） | status=operational | — | status=attacked, damage_level=1 |

若 AI 误提「累计 2847 人丧生」并填成 personnel_killed=2847，单次合并会被上限截断（如最多 +500），避免一次写入导致数据剧增。

### 4. 前端验证效果

- **事件脉络**：出现一条新条目，summary 为上述 1–2 句概括，category=alert、severity=high。
- **装备毁伤面板**：美军「阵亡」+2、「受伤」+14、「战机」+1；基地毁/损数字随 bases_damaged +1 更新。
- **地图**：阿萨德基地、伊斯法罕对应点位显示为「遭袭」状态（脉冲/标色随现有地图逻辑）。
- **API**：`GET /api/situation` 中 `usForces.combatLosses`、`usForces.keyLocations`（含 status/damage_level）为更新后值；`lastUpdated` 为合并后时间。

### 5. 快速自测命令

```bash
# 仅测提取逻辑（不写库）：用示例文本调 AI 提取，看是否得到增量 + key_location_updates
cd crawler && python3 -c "
from extractor_ai import extract_from_news
text = '''伊朗向伊拉克阿萨德空军基地发射导弹，此次袭击造成 2 名美军死亡、14 人受伤，1 架战机受损。'''
out = extract_from_news(text)
print('combat_losses_delta:', out.get('combat_losses_delta'))
print('key_location_updates:', out.get('key_location_updates'))
"
```

期望：`combat_losses_delta.us` 含 personnel_killed=2、personnel_wounded=14、aircraft=1 等增量；`key_location_updates` 含阿萨德 side=us 等条目。

### 地图打击数据（与前端攻击动画统一格式）

爬虫/AI 若输出以下字段，`db_merge` 会写入 `map_strike_source`、`map_strike_line`，`GET /api/situation` 的 `mapData.strikeSources` / `mapData.strikeLines` 会更新，前端可直接追加打击线与飞行动画。

- **map_strike_sources**（可选）：`[{ "id": "israel"|"lincoln"|"ford", "name": "显示名", "lng": 经度, "lat": 纬度 }]`，与 seed 中打击源 id 一致时可覆盖位置。
- **map_strike_lines**（可选）：`[{ "source_id": "israel"|"lincoln"|"ford", "target_lng", "target_lat", "target_name": "目标名", "struck_at": "ISO 时间" }]`，每条追加一条打击线（不删已有），便于按时间回放。

示例：`{ "map_strike_lines": [{ "source_id": "israel", "target_lng": 51.916, "target_lat": 33.666, "target_name": "纳坦兹", "struck_at": "2026-03-01T02:04:00.000Z" }] }`

---

## 冲突强度 (impact_score)

| 分数 | 地图效果   |
|------|------------|
| 1–3  | 绿色点     |
| 4–6  | 橙色闪烁   |
| 7–10 | 红色脉冲扩散 |

## API

- `GET http://localhost:8000/events`：返回事件列表与冲突统计（Python 服务直连）
- `GET http://localhost:3001/api/events`：从 Node 读取（推荐，含 WebSocket 同步）

## 本地验证链路

按下面任选一种方式，确认「抓取 → 清洗 → 去重 → 映射 → 写表 → 通知」整条链路正常。

### 方式一：最小验证（不启动前端）

1. **启动 API（必须）**
   ```bash
   npm run api
   ```
   保持运行，默认 `http://localhost:3001`。

2. **安装爬虫依赖并跑一轮流水线**
   ```bash
   cd crawler && pip install -r requirements.txt
   python -c "
   from pipeline import run_full_pipeline
   from config import DB_PATH, API_BASE
   n_fetched, n_news, n_panel = run_full_pipeline(db_path=DB_PATH, api_base=API_BASE, translate=True, notify=True)
   print('抓取:', n_fetched, '去重新增:', n_news, '面板写入:', n_panel)
   "
   ```
   - 有网络且有关键词命中时，应看到非零数字；无网络或全被过滤则为 `0 0 0`。
   - 若报错 `module 'socket' has no attribute 'settimeout'`，已修复为 `setdefaulttimeout`，请拉取最新代码。

3. **查库确认**
   ```bash
   sqlite3 server/data.db "SELECT COUNT(*) FROM situation_update; SELECT COUNT(*) FROM news_content;"
   ```
   或浏览器打开 `http://localhost:3001/api/db/dashboard`，看 `situation_update`、`news_content` 是否有数据。

4. **确认态势接口**
   ```bash
   curl -s http://localhost:3001/api/situation | head -c 500
   ```
   应包含 `lastUpdated`、`recentUpdates` 等。

### 方式二：用现有验证脚本（推荐）

1. 终端 1：`npm run api`
2. 终端 2（可选）：`npm run gdelt`（会定时跑 RSS + GDELT）
3. 执行验证脚本：
   ```bash
   ./scripts/verify-pipeline.sh
   ```
   若爬虫未启动想一并测爬虫，可：
   ```bash
   ./scripts/verify-pipeline.sh --start-crawler
   ```
   脚本会检查：API 健康、态势数据、爬虫状态、资讯表、战损字段、通知接口。

### 方式三：只测 RSS 抓取（不写库）

```bash
npm run crawler:test
```
输出为「RSS 抓取: N 条」。0 条时检查网络或 `config.py` 里 `RSS_FEEDS` / `KEYWORDS`。

### 常见问题

| 现象 | 可能原因 |
|------|----------|
| 抓取 0 条 | 网络不通、RSS 被墙、关键词无一命中 |
| `situation_update` 为空 | 去重后无新增，或未跑流水线（只跑了 `fetch_all` 未跑 `run_full_pipeline`） |
| 前端事件脉络不刷新 | 未启动 `npm run api` 或 WebSocket 未连上（需通过 Vite 代理访问前端） |
| 翻译/AI 清洗很慢或报错 | 设 `TRANSLATE_DISABLED=1` 或 `CLEANER_AI_DISABLED=1` 可跳过，用规则兜底 |

---

## 故障排查

| 现象 | 可能原因 | 排查 |
|------|----------|------|
| 事件脉络始终为空 | 未启动 GDELT 爬虫 | 另开终端运行 `npm run gdelt`，观察是否有 `GDELT 更新 X 条事件` 输出 |
| 事件脉络不刷新 | WebSocket 未连上 | 确认 `npm run api` 已启动，前端需通过 `npm run dev` 访问（Vite 会代理 /ws） |
| GDELT 抓取失败 | 系统代理超时 / ProxyError | 爬虫默认直连，不走代理；若需代理请设 `CRAWLER_USE_PROXY=1` |
| GDELT 抓取失败 | 网络 / GDELT API 限流 | 检查 Python 终端报错；GDELT 在国外，国内网络可能较慢或超时 |
| 新闻条数为 0 | RSS 源被墙或关键词不匹配 | 检查 crawler/config.py 中 RSS_FEEDS、KEYWORDS；国内需代理 |
| **返回数据偏老** | GDELT 默认 3 个月内按相关性 | 设置 `GDELT_TIMESPAN=1d` 限制为近日；加 `sort=datedesc` 最新优先 |
