Compare commits
3 Commits
24d0593e12
...
004d10b283
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
004d10b283 | ||
|
|
4a8fff5a00 | ||
|
|
91d9e48e1e |
15
README.md
15
README.md
@@ -41,7 +41,13 @@ npm run api:seed
|
|||||||
npm run api
|
npm run api
|
||||||
```
|
```
|
||||||
|
|
||||||
开发时需同时运行前端与 API:
|
开发时可用一键启动(推荐):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
或分终端分别运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 终端 1
|
# 终端 1
|
||||||
@@ -53,6 +59,13 @@ npm run dev
|
|||||||
|
|
||||||
API 会由 Vite 代理到 `/api`,前端通过 `/api/situation` 获取完整态势数据。数据库文件位于 `server/data.db`,可通过修改表数据实现动态调整。
|
API 会由 Vite 代理到 `/api`,前端通过 `/api/situation` 获取完整态势数据。数据库文件位于 `server/data.db`,可通过修改表数据实现动态调整。
|
||||||
|
|
||||||
|
### 爬虫不生效时
|
||||||
|
|
||||||
|
1. 测试 RSS 抓取:`npm run crawler:test`(需网络,返回抓取条数)
|
||||||
|
2. 单独启动爬虫查看日志:`npm run gdelt`(另开终端)
|
||||||
|
3. 查看爬虫状态:`curl http://localhost:8000/crawler/status`(需爬虫服务已启动)
|
||||||
|
4. 数据库面板 `/db` 每 30 秒自动刷新,可观察 situation_update 条数是否增加
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
105
crawler/README.md
Normal file
105
crawler/README.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# 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 新闻 (situation_update) — 主事件脉络来源
|
||||||
|
|
||||||
|
| 项目 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 源 | 多国主流媒体:美(Reuters/NYT)、英(BBC)、法(France 24)、俄(TASS/RT)、中(Xinhua/CGTN)、伊(Press TV)、卡塔尔(Al Jazeera) |
|
||||||
|
| 过滤 | 标题/摘要需含 `KEYWORDS` 之一(iran、usa、strike、military 等) |
|
||||||
|
| 更新 | 爬虫 45 秒拉一次(`RSS_INTERVAL_SEC`),优先保证事件脉络 |
|
||||||
|
| 优先级 | 启动时先拉 RSS,再拉 GDELT |
|
||||||
|
|
||||||
|
**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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**事件脉络可实时更新**:爬虫抓取后 → 写入 SQLite → 调用 Node 通知 → WebSocket 广播 → 前端自动刷新。
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
新增 `deep-translator`:GDELT 与 RSS 新闻入库前自动翻译为中文。
|
||||||
|
|
||||||
|
## 运行(需同时启动 3 个服务)
|
||||||
|
|
||||||
|
| 终端 | 命令 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | `npm run api` | Node API + WebSocket(必须) |
|
||||||
|
| 2 | `npm run gdelt` | GDELT + RSS 爬虫(**事件脉络数据来源**) |
|
||||||
|
| 3 | `npm run dev` | 前端开发 |
|
||||||
|
|
||||||
|
**事件脉络不更新时**:多半是未启动 `npm run gdelt`。只跑 `npm run api` 时,事件脉络会显示空或仅有缓存。
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
GDELT API → 抓取(60s) → SQLite (gdelt_events, conflict_stats) → POST /api/crawler/notify
|
||||||
|
↓
|
||||||
|
Node 更新 situation.updated_at + WebSocket 广播
|
||||||
|
↓
|
||||||
|
前端实时展示
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
环境变量:
|
||||||
|
|
||||||
|
- `DB_PATH`: SQLite 路径,默认 `../server/data.db`
|
||||||
|
- `API_BASE`: Node API 地址,默认 `http://localhost:3001`
|
||||||
|
- `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 清洗,仅用规则截断
|
||||||
|
|
||||||
|
## 冲突强度 (impact_score)
|
||||||
|
|
||||||
|
| 分数 | 地图效果 |
|
||||||
|
|------|------------|
|
||||||
|
| 1–3 | 绿色点 |
|
||||||
|
| 4–6 | 橙色闪烁 |
|
||||||
|
| 7–10 | 红色脉冲扩散 |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `GET http://localhost:8000/events`:返回事件列表与冲突统计(Python 服务直连)
|
||||||
|
- `GET http://localhost:3001/api/events`:从 Node 读取(推荐,含 WebSocket 同步)
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
| 现象 | 可能原因 | 排查 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 事件脉络始终为空 | 未启动 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` 最新优先 |
|
||||||
BIN
crawler/__pycache__/cleaner_ai.cpython-39.pyc
Normal file
BIN
crawler/__pycache__/cleaner_ai.cpython-39.pyc
Normal file
Binary file not shown.
BIN
crawler/__pycache__/config.cpython-39.pyc
Normal file
BIN
crawler/__pycache__/config.cpython-39.pyc
Normal file
Binary file not shown.
BIN
crawler/__pycache__/db_writer.cpython-39.pyc
Normal file
BIN
crawler/__pycache__/db_writer.cpython-39.pyc
Normal file
Binary file not shown.
BIN
crawler/__pycache__/parser.cpython-39.pyc
Normal file
BIN
crawler/__pycache__/parser.cpython-39.pyc
Normal file
Binary file not shown.
BIN
crawler/__pycache__/parser_ai.cpython-39.pyc
Normal file
BIN
crawler/__pycache__/parser_ai.cpython-39.pyc
Normal file
Binary file not shown.
BIN
crawler/__pycache__/realtime_conflict_service.cpython-39.pyc
Normal file
BIN
crawler/__pycache__/realtime_conflict_service.cpython-39.pyc
Normal file
Binary file not shown.
BIN
crawler/__pycache__/translate_utils.cpython-39.pyc
Normal file
BIN
crawler/__pycache__/translate_utils.cpython-39.pyc
Normal file
Binary file not shown.
87
crawler/cleaner_ai.py
Normal file
87
crawler/cleaner_ai.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
AI 清洗新闻数据,严格按面板字段约束输出
|
||||||
|
面板 EventTimelinePanel 所需:summary(≤120字)、category(枚举)、severity(枚举)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
CLEANER_AI_DISABLED = os.environ.get("CLEANER_AI_DISABLED", "0") == "1"
|
||||||
|
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.1")
|
||||||
|
|
||||||
|
# 面板 schema:必须与 EventTimelinePanel / SituationUpdate 一致
|
||||||
|
SUMMARY_MAX_LEN = 120 # 面板 line-clamp-2 展示
|
||||||
|
CATEGORIES = ("deployment", "alert", "intel", "diplomatic", "other")
|
||||||
|
SEVERITIES = ("low", "medium", "high", "critical")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_summary(text: str, max_len: int = SUMMARY_MAX_LEN) -> str:
|
||||||
|
"""确保 summary 符合面板:纯文本、无换行、限制长度"""
|
||||||
|
if not text or not isinstance(text, str):
|
||||||
|
return ""
|
||||||
|
s = re.sub(r"\s+", " ", str(text).strip())
|
||||||
|
s = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", s) # 去除控制字符
|
||||||
|
return s[:max_len].rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_clean(text: str, max_len: int = SUMMARY_MAX_LEN) -> str:
|
||||||
|
"""规则清洗:去空白、去控制符、截断"""
|
||||||
|
return _sanitize_summary(text, max_len)
|
||||||
|
|
||||||
|
|
||||||
|
def _call_ollama_summary(text: str, max_len: int, timeout: int = 6) -> Optional[str]:
|
||||||
|
"""调用 Ollama 提炼摘要,输出须为纯文本、≤max_len 字"""
|
||||||
|
if CLEANER_AI_DISABLED or not text or len(str(text).strip()) < 5:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
prompt = f"""将新闻提炼为1-2句简洁中文事实,直接输出纯文本,不要标号、引号、解释。限{max_len}字内。
|
||||||
|
|
||||||
|
原文:{str(text)[:350]}
|
||||||
|
|
||||||
|
输出:"""
|
||||||
|
r = requests.post(
|
||||||
|
"http://localhost:11434/api/chat",
|
||||||
|
json={
|
||||||
|
"model": OLLAMA_MODEL,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"stream": False,
|
||||||
|
"options": {"num_predict": 150},
|
||||||
|
},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return None
|
||||||
|
out = (r.json().get("message", {}).get("content", "") or "").strip()
|
||||||
|
out = re.sub(r"^[\d\.\-\*\s]+", "", out) # 去编号
|
||||||
|
out = re.sub(r"^['\"\s]+|['\"\s]+$", "", out)
|
||||||
|
out = _sanitize_summary(out, max_len)
|
||||||
|
if out and len(out) > 3:
|
||||||
|
return out
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def clean_news_for_panel(text: str, max_len: int = SUMMARY_MAX_LEN) -> str:
|
||||||
|
"""清洗 summary 字段,供 EventTimelinePanel 展示。输出必为≤max_len 的纯文本"""
|
||||||
|
if not text or not isinstance(text, str):
|
||||||
|
return ""
|
||||||
|
t = str(text).strip()
|
||||||
|
if not t:
|
||||||
|
return ""
|
||||||
|
res = _call_ollama_summary(t, max_len, timeout=6)
|
||||||
|
if res:
|
||||||
|
return res
|
||||||
|
return _rule_clean(t, max_len)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_category(cat: str) -> str:
|
||||||
|
"""确保 category 在面板枚举内"""
|
||||||
|
return cat if cat in CATEGORIES else "other"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_severity(sev: str) -> str:
|
||||||
|
"""确保 severity 在面板枚举内"""
|
||||||
|
return sev if sev in SEVERITIES else "medium"
|
||||||
49
crawler/config.py
Normal file
49
crawler/config.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""爬虫配置"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 数据库路径(与 server 共用 SQLite)
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", str(PROJECT_ROOT / "server" / "data.db"))
|
||||||
|
|
||||||
|
# Node API 地址(用于通知推送)
|
||||||
|
API_BASE = os.environ.get("API_BASE", "http://localhost:3001")
|
||||||
|
|
||||||
|
# 抓取间隔(秒)
|
||||||
|
CRAWL_INTERVAL = int(os.environ.get("CRAWL_INTERVAL", "300"))
|
||||||
|
|
||||||
|
# RSS 源:世界主流媒体,覆盖美伊/中东多视角
|
||||||
|
RSS_FEEDS = [
|
||||||
|
# 美国
|
||||||
|
"https://feeds.reuters.com/reuters/topNews",
|
||||||
|
"https://rss.nytimes.com/services/xml/rss/nyt/World.xml",
|
||||||
|
# 英国
|
||||||
|
"https://feeds.bbci.co.uk/news/world/rss.xml",
|
||||||
|
"https://feeds.bbci.co.uk/news/world/middle_east/rss.xml",
|
||||||
|
"https://www.theguardian.com/world/rss",
|
||||||
|
# 法国
|
||||||
|
"https://www.france24.com/en/rss",
|
||||||
|
# 德国
|
||||||
|
"https://rss.dw.com/xml/rss-en-world",
|
||||||
|
# 俄罗斯
|
||||||
|
"https://tass.com/rss/v2.xml",
|
||||||
|
"https://www.rt.com/rss/",
|
||||||
|
# 中国
|
||||||
|
"https://english.news.cn/rss/world.xml",
|
||||||
|
"https://www.cgtn.com/rss/world",
|
||||||
|
# 伊朗
|
||||||
|
"https://www.presstv.ir/rss",
|
||||||
|
# 卡塔尔(中东)
|
||||||
|
"https://www.aljazeera.com/xml/rss/all.xml",
|
||||||
|
"https://www.aljazeera.com/xml/rss/middleeast.xml",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 关键词过滤:至少匹配一个才会入库
|
||||||
|
KEYWORDS = [
|
||||||
|
"iran", "iranian", "tehran", "以色列", "israel",
|
||||||
|
"usa", "us ", "american", "美军", "美国",
|
||||||
|
"middle east", "中东", "persian gulf", "波斯湾",
|
||||||
|
"strike", "attack", "military", "missile", "核", "nuclear",
|
||||||
|
"carrier", "航母", "houthi", "胡塞", "hamas",
|
||||||
|
]
|
||||||
126
crawler/db_merge.py
Normal file
126
crawler/db_merge.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
将 AI 提取的结构化数据合并到 SQLite
|
||||||
|
与 panel schema 及 situationData.getSituation 对齐,支持回放
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", str(PROJECT_ROOT / "server" / "data.db"))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_tables(conn: sqlite3.Connection) -> None:
|
||||||
|
"""确保所需表存在(与 db.js 一致)"""
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS situation_update (
|
||||||
|
id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, category TEXT NOT NULL,
|
||||||
|
summary TEXT NOT NULL, severity TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS combat_losses (
|
||||||
|
side TEXT PRIMARY KEY CHECK (side IN ('us', 'iran')),
|
||||||
|
bases_destroyed INTEGER NOT NULL, bases_damaged INTEGER NOT NULL,
|
||||||
|
personnel_killed INTEGER NOT NULL, personnel_wounded INTEGER NOT NULL,
|
||||||
|
aircraft INTEGER NOT NULL, warships INTEGER NOT NULL, armor INTEGER NOT NULL, vehicles INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE combat_losses ADD COLUMN civilian_killed INTEGER NOT NULL DEFAULT 0")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE combat_losses ADD COLUMN civilian_wounded INTEGER NOT NULL DEFAULT 0")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE combat_losses ADD COLUMN updated_at TEXT DEFAULT (datetime('now'))")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS wall_street_trend (id INTEGER PRIMARY KEY AUTOINCREMENT, time TEXT NOT NULL, value INTEGER NOT NULL)")
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS retaliation_current (id INTEGER PRIMARY KEY CHECK (id = 1), value INTEGER NOT NULL)")
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS retaliation_history (id INTEGER PRIMARY KEY AUTOINCREMENT, time TEXT NOT NULL, value INTEGER NOT NULL)")
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS situation (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL, updated_at TEXT NOT NULL)")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool:
|
||||||
|
"""将提取数据合并到 DB,返回是否有更新"""
|
||||||
|
path = db_path or DB_PATH
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return False
|
||||||
|
conn = sqlite3.connect(path, timeout=10)
|
||||||
|
try:
|
||||||
|
_ensure_tables(conn)
|
||||||
|
updated = False
|
||||||
|
# situation_update
|
||||||
|
if "situation_update" in extracted:
|
||||||
|
u = extracted["situation_update"]
|
||||||
|
uid = f"ai_{hash(u.get('summary','')+u.get('timestamp','')) % 10**10}"
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO situation_update (id, timestamp, category, summary, severity) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(uid, u.get("timestamp", ""), u.get("category", "other"), u.get("summary", "")[:500], u.get("severity", "medium")),
|
||||||
|
)
|
||||||
|
if conn.total_changes > 0:
|
||||||
|
updated = True
|
||||||
|
# combat_losses:增量叠加到当前值
|
||||||
|
if "combat_losses_delta" in extracted:
|
||||||
|
for side, delta in extracted["combat_losses_delta"].items():
|
||||||
|
if side not in ("us", "iran"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT personnel_killed,personnel_wounded,civilian_killed,civilian_wounded,bases_destroyed,bases_damaged,aircraft,warships,armor,vehicles FROM combat_losses WHERE side = ?",
|
||||||
|
(side,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
cur = {
|
||||||
|
"personnel_killed": row[0], "personnel_wounded": row[1], "civilian_killed": row[2] or 0,
|
||||||
|
"civilian_wounded": row[3] or 0, "bases_destroyed": row[4], "bases_damaged": row[5],
|
||||||
|
"aircraft": row[6], "warships": row[7], "armor": row[8], "vehicles": row[9],
|
||||||
|
}
|
||||||
|
pk = max(0, (cur["personnel_killed"] or 0) + delta.get("personnel_killed", 0))
|
||||||
|
pw = max(0, (cur["personnel_wounded"] or 0) + delta.get("personnel_wounded", 0))
|
||||||
|
ck = max(0, (cur["civilian_killed"] or 0) + delta.get("civilian_killed", 0))
|
||||||
|
cw = max(0, (cur["civilian_wounded"] or 0) + delta.get("civilian_wounded", 0))
|
||||||
|
bd = max(0, (cur["bases_destroyed"] or 0) + delta.get("bases_destroyed", 0))
|
||||||
|
bm = max(0, (cur["bases_damaged"] or 0) + delta.get("bases_damaged", 0))
|
||||||
|
ac = max(0, (cur["aircraft"] or 0) + delta.get("aircraft", 0))
|
||||||
|
ws = max(0, (cur["warships"] or 0) + delta.get("warships", 0))
|
||||||
|
ar = max(0, (cur["armor"] or 0) + delta.get("armor", 0))
|
||||||
|
vh = max(0, (cur["vehicles"] or 0) + delta.get("vehicles", 0))
|
||||||
|
ts = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE combat_losses SET personnel_killed=?, personnel_wounded=?, civilian_killed=?, civilian_wounded=?,
|
||||||
|
bases_destroyed=?, bases_damaged=?, aircraft=?, warships=?, armor=?, vehicles=?, updated_at=? WHERE side=?""",
|
||||||
|
(pk, pw, ck, cw, bd, bm, ac, ws, ar, vh, ts, side),
|
||||||
|
)
|
||||||
|
if conn.total_changes > 0:
|
||||||
|
updated = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# retaliation
|
||||||
|
if "retaliation" in extracted:
|
||||||
|
r = extracted["retaliation"]
|
||||||
|
conn.execute("INSERT OR REPLACE INTO retaliation_current (id, value) VALUES (1, ?)", (r["value"],))
|
||||||
|
conn.execute("INSERT INTO retaliation_history (time, value) VALUES (?, ?)", (r["time"], r["value"]))
|
||||||
|
updated = True
|
||||||
|
# wall_street_trend
|
||||||
|
if "wall_street" in extracted:
|
||||||
|
w = extracted["wall_street"]
|
||||||
|
conn.execute("INSERT INTO wall_street_trend (time, value) VALUES (?, ?)", (w["time"], w["value"]))
|
||||||
|
updated = True
|
||||||
|
if updated:
|
||||||
|
conn.execute("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)", (datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"),))
|
||||||
|
conn.commit()
|
||||||
|
return updated
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
raise e
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
110
crawler/db_writer.py
Normal file
110
crawler/db_writer.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""写入 SQLite 并确保 situation_update 表存在"""
|
||||||
|
import sqlite3
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from config import DB_PATH
|
||||||
|
|
||||||
|
CATEGORIES = ("deployment", "alert", "intel", "diplomatic", "other")
|
||||||
|
SEVERITIES = ("low", "medium", "high", "critical")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_table(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS situation_update (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_id(title: str, url: str, published: str) -> str:
|
||||||
|
raw = f"{title}|{url}|{published}"
|
||||||
|
return "nw_" + hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _to_utc_iso(dt: datetime) -> str:
|
||||||
|
if dt.tzinfo:
|
||||||
|
dt = dt.astimezone(timezone.utc)
|
||||||
|
return dt.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
|
||||||
|
|
||||||
|
def insert_update(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
title: str,
|
||||||
|
summary: str,
|
||||||
|
url: str,
|
||||||
|
published: datetime,
|
||||||
|
category: str = "other",
|
||||||
|
severity: str = "medium",
|
||||||
|
) -> bool:
|
||||||
|
"""插入一条更新,若 id 已存在则跳过。返回是否插入了新记录。"""
|
||||||
|
_ensure_table(conn)
|
||||||
|
ts = _to_utc_iso(published)
|
||||||
|
uid = _make_id(title, url, ts)
|
||||||
|
if category not in CATEGORIES:
|
||||||
|
category = "other"
|
||||||
|
if severity not in SEVERITIES:
|
||||||
|
severity = "medium"
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO situation_update (id, timestamp, category, summary, severity) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(uid, ts, category, summary[:500], severity),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return conn.total_changes > 0
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def touch_situation_updated_at(conn: sqlite3.Connection) -> None:
|
||||||
|
"""更新 situation 表的 updated_at"""
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)",
|
||||||
|
(datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"),),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def write_updates(updates: list[dict]) -> int:
|
||||||
|
"""
|
||||||
|
updates: [{"title","summary","url","published","category","severity"}, ...]
|
||||||
|
返回新增条数。
|
||||||
|
"""
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
return 0
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
|
try:
|
||||||
|
count = 0
|
||||||
|
for u in updates:
|
||||||
|
pub = u.get("published")
|
||||||
|
if isinstance(pub, str):
|
||||||
|
try:
|
||||||
|
pub = datetime.fromisoformat(pub.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
pub = datetime.utcnow()
|
||||||
|
elif pub is None:
|
||||||
|
pub = datetime.utcnow()
|
||||||
|
ok = insert_update(
|
||||||
|
conn,
|
||||||
|
title=u.get("title", "")[:200],
|
||||||
|
summary=u.get("summary", "") or u.get("title", ""),
|
||||||
|
url=u.get("url", ""),
|
||||||
|
published=pub,
|
||||||
|
category=u.get("category", "other"),
|
||||||
|
severity=u.get("severity", "medium"),
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
count += 1
|
||||||
|
if count > 0:
|
||||||
|
touch_situation_updated_at(conn)
|
||||||
|
return count
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
100
crawler/extractor_ai.py
Normal file
100
crawler/extractor_ai.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
从新闻文本中 AI 提取结构化数据,映射到面板 schema
|
||||||
|
输出符合 panel_schema 的字段,供 db_merge 写入
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from panel_schema import validate_category, validate_severity, validate_summary
|
||||||
|
|
||||||
|
CLEANER_AI_DISABLED = os.environ.get("CLEANER_AI_DISABLED", "0") == "1"
|
||||||
|
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.1")
|
||||||
|
|
||||||
|
|
||||||
|
def _call_ollama_extract(text: str, timeout: int = 10) -> Optional[Dict[str, Any]]:
|
||||||
|
"""调用 Ollama 提取结构化数据。输出 JSON,仅包含新闻中可明确推断的字段"""
|
||||||
|
if CLEANER_AI_DISABLED or not text or len(str(text).strip()) < 10:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
prompt = f"""从以下美伊/中东新闻中提取可推断的数值,输出 JSON,仅包含有明确依据的字段。无依据则省略该字段。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- summary: 1-2句中文事实,≤80字
|
||||||
|
- category: deployment|alert|intel|diplomatic|other
|
||||||
|
- severity: low|medium|high|critical
|
||||||
|
- us_personnel_killed, iran_personnel_killed 等:仅当新闻明确提及具体数字时填写
|
||||||
|
- retaliation_sentiment: 0-100,仅当新闻涉及伊朗报复情绪时
|
||||||
|
- wall_street_value: 0-100,仅当新闻涉及美股/市场反应时
|
||||||
|
|
||||||
|
原文:{str(text)[:500]}
|
||||||
|
|
||||||
|
直接输出 JSON,不要解释:"""
|
||||||
|
r = requests.post(
|
||||||
|
"http://localhost:11434/api/chat",
|
||||||
|
json={
|
||||||
|
"model": OLLAMA_MODEL,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"stream": False,
|
||||||
|
"options": {"num_predict": 256},
|
||||||
|
},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return None
|
||||||
|
raw = (r.json().get("message", {}).get("content", "") or "").strip()
|
||||||
|
raw = re.sub(r"^```\w*\s*|\s*```$", "", raw)
|
||||||
|
return json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
从新闻文本提取结构化数据,严格符合面板 schema
|
||||||
|
返回: { situation_update?, combat_losses_delta?, retaliation?, wall_street?, ... }
|
||||||
|
"""
|
||||||
|
ts = timestamp or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
parsed = _call_ollama_extract(text)
|
||||||
|
if not parsed:
|
||||||
|
return out
|
||||||
|
# situation_update
|
||||||
|
if parsed.get("summary"):
|
||||||
|
out["situation_update"] = {
|
||||||
|
"summary": validate_summary(str(parsed["summary"])[:120], 120),
|
||||||
|
"category": validate_category(str(parsed.get("category", "other")).lower()),
|
||||||
|
"severity": validate_severity(str(parsed.get("severity", "medium")).lower()),
|
||||||
|
"timestamp": ts,
|
||||||
|
}
|
||||||
|
# combat_losses 增量(仅数字字段)
|
||||||
|
loss_us = {}
|
||||||
|
loss_ir = {}
|
||||||
|
for k in ["personnel_killed", "personnel_wounded", "civilian_killed", "civilian_wounded", "bases_destroyed", "bases_damaged", "aircraft", "warships", "armor", "vehicles"]:
|
||||||
|
uk = f"us_{k}"
|
||||||
|
ik = f"iran_{k}"
|
||||||
|
if uk in parsed and isinstance(parsed[uk], (int, float)):
|
||||||
|
loss_us[k] = max(0, int(parsed[uk]))
|
||||||
|
if ik in parsed and isinstance(parsed[ik], (int, float)):
|
||||||
|
loss_ir[k] = max(0, int(parsed[ik]))
|
||||||
|
if loss_us or loss_ir:
|
||||||
|
out["combat_losses_delta"] = {}
|
||||||
|
if loss_us:
|
||||||
|
out["combat_losses_delta"]["us"] = loss_us
|
||||||
|
if loss_ir:
|
||||||
|
out["combat_losses_delta"]["iran"] = loss_ir
|
||||||
|
# retaliation
|
||||||
|
if "retaliation_sentiment" in parsed:
|
||||||
|
v = parsed["retaliation_sentiment"]
|
||||||
|
if isinstance(v, (int, float)) and 0 <= v <= 100:
|
||||||
|
out["retaliation"] = {"value": int(v), "time": ts}
|
||||||
|
# wall_street
|
||||||
|
if "wall_street_value" in parsed:
|
||||||
|
v = parsed["wall_street_value"]
|
||||||
|
if isinstance(v, (int, float)) and 0 <= v <= 100:
|
||||||
|
out["wall_street"] = {"time": ts, "value": int(v)}
|
||||||
|
return out
|
||||||
49
crawler/extractor_rules.py
Normal file
49
crawler/extractor_rules.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
基于规则的新闻数据提取(无需 Ollama)
|
||||||
|
从新闻文本中提取战损、报复情绪等数值,供 db_merge 写入
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _first_int(text: str, pattern: str) -> Optional[int]:
|
||||||
|
m = re.search(pattern, text, re.I)
|
||||||
|
if m and m.group(1) and m.group(1).replace(",", "").isdigit():
|
||||||
|
return max(0, int(m.group(1).replace(",", "")))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
规则提取:匹配数字+关键词,输出符合 panel schema 的字段(无需 Ollama)
|
||||||
|
"""
|
||||||
|
ts = timestamp or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
t = (text or "").lower()
|
||||||
|
|
||||||
|
loss_us, loss_ir = {}, {}
|
||||||
|
v = _first_int(t, r"(?:us|american|u\.?s\.?)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|killed|dead)")
|
||||||
|
if v is not None:
|
||||||
|
loss_us["personnel_killed"] = v
|
||||||
|
v = _first_int(t, r"(\d+)[\s\w]*(?:us|american)[\s\w]*(?:troop|soldier|killed|dead)")
|
||||||
|
if v is not None:
|
||||||
|
loss_us["personnel_killed"] = v
|
||||||
|
v = _first_int(t, r"(?:iran|iranian)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|killed|dead)")
|
||||||
|
if v is not None:
|
||||||
|
loss_ir["personnel_killed"] = v
|
||||||
|
v = _first_int(t, r"(\d+)[\s\w]*(?:iranian|iran)[\s\w]*(?:troop|soldier|killed|dead)")
|
||||||
|
if v is not None:
|
||||||
|
loss_ir["personnel_killed"] = v
|
||||||
|
|
||||||
|
if loss_us:
|
||||||
|
out.setdefault("combat_losses_delta", {})["us"] = loss_us
|
||||||
|
if loss_ir:
|
||||||
|
out.setdefault("combat_losses_delta", {})["iran"] = loss_ir
|
||||||
|
if "retaliat" in t or "revenge" in t or "报复" in t:
|
||||||
|
out["retaliation"] = {"value": 75, "time": ts}
|
||||||
|
if "wall street" in t or " dow " in t or "s&p" in t or "market slump" in t or "stock fall" in t or "美股" in t:
|
||||||
|
out["wall_street"] = {"time": ts, "value": 55}
|
||||||
|
|
||||||
|
return out
|
||||||
57
crawler/main.py
Normal file
57
crawler/main.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""爬虫入口:定时抓取 → 解析 → 入库 → 通知 API"""
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 确保能导入 config
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from config import DB_PATH, API_BASE, CRAWL_INTERVAL
|
||||||
|
from scrapers.rss_scraper import fetch_all
|
||||||
|
from db_writer import write_updates
|
||||||
|
|
||||||
|
|
||||||
|
def notify_api() -> bool:
|
||||||
|
"""调用 Node API 触发立即广播"""
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{API_BASE}/api/crawler/notify",
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
return resp.status == 200
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [warn] notify API failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def run_once() -> int:
|
||||||
|
items = fetch_all()
|
||||||
|
if not items:
|
||||||
|
return 0
|
||||||
|
n = write_updates(items)
|
||||||
|
if n > 0:
|
||||||
|
notify_api()
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print("Crawler started. DB:", DB_PATH)
|
||||||
|
print("API:", API_BASE, "| Interval:", CRAWL_INTERVAL, "s")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
n = run_once()
|
||||||
|
if n > 0:
|
||||||
|
print(f"[{time.strftime('%H:%M:%S')}] Inserted {n} new update(s)")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{time.strftime('%H:%M:%S')}] Error: {e}")
|
||||||
|
time.sleep(CRAWL_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
42
crawler/panel_schema.py
Normal file
42
crawler/panel_schema.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
前端面板完整数据 schema,与 DB / situationData / useReplaySituation 对齐
|
||||||
|
爬虫 + AI 清洗后的数据必须符合此 schema 才能正确更新前端
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, List, Literal, Optional, Tuple
|
||||||
|
|
||||||
|
# 事件脉络
|
||||||
|
SITUATION_UPDATE_CATEGORIES = ("deployment", "alert", "intel", "diplomatic", "other")
|
||||||
|
SITUATION_UPDATE_SEVERITIES = ("low", "medium", "high", "critical")
|
||||||
|
SUMMARY_MAX_LEN = 120
|
||||||
|
|
||||||
|
# 战损
|
||||||
|
CombatLossesRow = Dict[str, Any] # bases_destroyed, bases_damaged, personnel_killed, ...
|
||||||
|
|
||||||
|
# 时间序列(回放用)
|
||||||
|
TimeSeriesPoint = Tuple[str, int] # (ISO time, value)
|
||||||
|
|
||||||
|
# AI 可从新闻中提取的字段
|
||||||
|
EXTRACTABLE_FIELDS = {
|
||||||
|
"situation_update": ["summary", "category", "severity", "timestamp"],
|
||||||
|
"combat_losses": ["personnel_killed", "personnel_wounded", "civilian_killed", "civilian_wounded", "bases_destroyed", "bases_damaged", "aircraft", "warships", "armor", "vehicles"],
|
||||||
|
"retaliation": ["value"], # 0-100
|
||||||
|
"wall_street_trend": ["time", "value"], # 0-100
|
||||||
|
"conflict_stats": ["estimated_casualties", "estimated_strike_count"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_category(cat: str) -> str:
|
||||||
|
return cat if cat in SITUATION_UPDATE_CATEGORIES else "other"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_severity(sev: str) -> str:
|
||||||
|
return sev if sev in SITUATION_UPDATE_SEVERITIES else "medium"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_summary(s: str, max_len: int = SUMMARY_MAX_LEN) -> str:
|
||||||
|
import re
|
||||||
|
if not s or not isinstance(s, str):
|
||||||
|
return ""
|
||||||
|
t = re.sub(r"\s+", " ", str(s).strip())[:max_len]
|
||||||
|
return re.sub(r"[\x00-\x1f]", "", t).rstrip()
|
||||||
52
crawler/parser.py
Normal file
52
crawler/parser.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""新闻分类与严重度判定"""
|
||||||
|
import re
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
Category = Literal["deployment", "alert", "intel", "diplomatic", "other"]
|
||||||
|
Severity = Literal["low", "medium", "high", "critical"]
|
||||||
|
|
||||||
|
# 分类关键词
|
||||||
|
CAT_DEPLOYMENT = ["deploy", "carrier", "航母", "military build", "troop", "forces"]
|
||||||
|
CAT_ALERT = ["strike", "attack", "fire", "blast", "hit", "爆炸", "袭击", "打击"]
|
||||||
|
CAT_INTEL = ["satellite", "intel", "image", "surveillance", "卫星", "情报"]
|
||||||
|
CAT_DIPLOMATIC = ["talk", "negotiation", "diplomat", "sanction", "谈判", "制裁"]
|
||||||
|
|
||||||
|
|
||||||
|
def _match(text: str, words: list[str]) -> bool:
|
||||||
|
t = (text or "").lower()
|
||||||
|
for w in words:
|
||||||
|
if w.lower() in t:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def classify(text: str) -> Category:
|
||||||
|
if _match(text, CAT_ALERT):
|
||||||
|
return "alert"
|
||||||
|
if _match(text, CAT_DEPLOYMENT):
|
||||||
|
return "deployment"
|
||||||
|
if _match(text, CAT_INTEL):
|
||||||
|
return "intel"
|
||||||
|
if _match(text, CAT_DIPLOMATIC):
|
||||||
|
return "diplomatic"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def severity(text: str, category: Category) -> Severity:
|
||||||
|
t = (text or "").lower()
|
||||||
|
critical = [
|
||||||
|
"nuclear", "核", "strike", "attack", "killed", "dead", "casualty",
|
||||||
|
"war", "invasion", "袭击", "打击", "死亡",
|
||||||
|
]
|
||||||
|
high = [
|
||||||
|
"missile", "drone", "bomb", "explosion", "blasted", "fire",
|
||||||
|
"导弹", "无人机", "爆炸", "轰炸",
|
||||||
|
]
|
||||||
|
if _match(t, critical):
|
||||||
|
return "critical"
|
||||||
|
if _match(t, high) or category == "alert":
|
||||||
|
return "high"
|
||||||
|
if category == "deployment":
|
||||||
|
return "medium"
|
||||||
|
return "low"
|
||||||
101
crawler/parser_ai.py
Normal file
101
crawler/parser_ai.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
AI 新闻分类与严重度判定
|
||||||
|
优先使用 Ollama 本地模型(免费),失败则回退到规则
|
||||||
|
设置 PARSER_AI_DISABLED=1 可只用规则(更快)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from typing import Literal, Optional, Tuple
|
||||||
|
|
||||||
|
Category = Literal["deployment", "alert", "intel", "diplomatic", "other"]
|
||||||
|
Severity = Literal["low", "medium", "high", "critical"]
|
||||||
|
|
||||||
|
PARSER_AI_DISABLED = os.environ.get("PARSER_AI_DISABLED", "0") == "1"
|
||||||
|
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.1") # 或 qwen2.5:7b
|
||||||
|
|
||||||
|
_CATEGORIES = ("deployment", "alert", "intel", "diplomatic", "other")
|
||||||
|
_SEVERITIES = ("low", "medium", "high", "critical")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ai_response(text: str) -> Tuple[Category, Severity]:
|
||||||
|
"""从 AI 回复解析 category:severity"""
|
||||||
|
t = (text or "").strip().lower()
|
||||||
|
cat, sev = "other", "low"
|
||||||
|
for c in _CATEGORIES:
|
||||||
|
if c in t:
|
||||||
|
cat = c
|
||||||
|
break
|
||||||
|
for s in _SEVERITIES:
|
||||||
|
if s in t:
|
||||||
|
sev = s
|
||||||
|
break
|
||||||
|
return cat, sev # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def _call_ollama(text: str, timeout: int = 5) -> Optional[Tuple[Category, Severity]]:
|
||||||
|
"""调用 Ollama 本地模型。需先运行 ollama run llama3.1 或 qwen2.5:7b"""
|
||||||
|
if PARSER_AI_DISABLED:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
prompt = f"""Classify this news about US-Iran/middle east (one line only):
|
||||||
|
- category: deployment|alert|intel|diplomatic|other
|
||||||
|
- severity: low|medium|high|critical
|
||||||
|
|
||||||
|
News: {text[:300]}
|
||||||
|
|
||||||
|
Reply format: category:severity (e.g. alert:high)"""
|
||||||
|
r = requests.post(
|
||||||
|
"http://localhost:11434/api/chat",
|
||||||
|
json={
|
||||||
|
"model": OLLAMA_MODEL,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"stream": False,
|
||||||
|
"options": {"num_predict": 32},
|
||||||
|
},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return None
|
||||||
|
out = r.json().get("message", {}).get("content", "")
|
||||||
|
return _parse_ai_response(out)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_classify(text: str) -> Category:
|
||||||
|
from parser import classify
|
||||||
|
return classify(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_severity(text: str, category: Category) -> Severity:
|
||||||
|
from parser import severity
|
||||||
|
return severity(text, category)
|
||||||
|
|
||||||
|
|
||||||
|
def classify(text: str) -> Category:
|
||||||
|
"""分类。AI 失败时回退规则"""
|
||||||
|
res = _call_ollama(text)
|
||||||
|
if res:
|
||||||
|
return res[0]
|
||||||
|
return _rule_classify(text)
|
||||||
|
|
||||||
|
|
||||||
|
def severity(text: str, category: Category) -> Severity:
|
||||||
|
"""严重度。AI 失败时回退规则"""
|
||||||
|
res = _call_ollama(text)
|
||||||
|
if res:
|
||||||
|
return res[1]
|
||||||
|
return _rule_severity(text, category)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_and_severity(text: str) -> Tuple[Category, Severity]:
|
||||||
|
"""一次调用返回分类和严重度(减少 AI 调用)"""
|
||||||
|
if PARSER_AI_DISABLED:
|
||||||
|
from parser import classify, severity
|
||||||
|
c = classify(text)
|
||||||
|
return c, severity(text, c)
|
||||||
|
res = _call_ollama(text)
|
||||||
|
if res:
|
||||||
|
return res
|
||||||
|
return _rule_classify(text), _rule_severity(text, _rule_classify(text))
|
||||||
367
crawler/realtime_conflict_service.py
Normal file
367
crawler/realtime_conflict_service.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
GDELT 实时冲突抓取 + API 服务
|
||||||
|
核心数据源:GDELT Project,约 15 分钟级更新,含经纬度、事件编码、参与方、事件强度
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
# 直连外网,避免系统代理导致 ProxyError / 超时(需代理时设置 CRAWLER_USE_PROXY=1)
|
||||||
|
if os.environ.get("CRAWLER_USE_PROXY") != "1":
|
||||||
|
os.environ.setdefault("NO_PROXY", "*")
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
|
logging.getLogger("apscheduler.scheduler").setLevel(logging.ERROR)
|
||||||
|
app = FastAPI(title="GDELT Conflict Service")
|
||||||
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"])
|
||||||
|
|
||||||
|
# 配置
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", str(PROJECT_ROOT / "server" / "data.db"))
|
||||||
|
API_BASE = os.environ.get("API_BASE", "http://localhost:3001")
|
||||||
|
QUERY = os.environ.get("GDELT_QUERY", "United States Iran military")
|
||||||
|
MAX_RECORDS = int(os.environ.get("GDELT_MAX_RECORDS", "30"))
|
||||||
|
FETCH_INTERVAL_SEC = int(os.environ.get("FETCH_INTERVAL_SEC", "60"))
|
||||||
|
RSS_INTERVAL_SEC = int(os.environ.get("RSS_INTERVAL_SEC", "60")) # 每分钟抓取世界主流媒体
|
||||||
|
# 时间范围:1h=1小时 1d=1天 1week=1周;不设则默认 3 个月(易返回旧文)
|
||||||
|
GDELT_TIMESPAN = os.environ.get("GDELT_TIMESPAN", "1d")
|
||||||
|
# 设为 1 则跳过 GDELT,仅用 RSS 新闻作为事件脉络(GDELT 国外可能无法访问)
|
||||||
|
GDELT_DISABLED = os.environ.get("GDELT_DISABLED", "0") == "1"
|
||||||
|
|
||||||
|
# 伊朗攻击源(无经纬度时默认)
|
||||||
|
IRAN_COORD = [51.3890, 35.6892] # Tehran [lng, lat]
|
||||||
|
|
||||||
|
# 请求直连,不经过系统代理(避免 ProxyError / 代理超时)
|
||||||
|
_REQ_KW = {"timeout": 15, "headers": {"User-Agent": "US-Iran-Dashboard/1.0"}}
|
||||||
|
if os.environ.get("CRAWLER_USE_PROXY") != "1":
|
||||||
|
_REQ_KW["proxies"] = {"http": None, "https": None}
|
||||||
|
|
||||||
|
EVENT_CACHE: List[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 冲突强度评分 (1–10)
|
||||||
|
# ==========================
|
||||||
|
def calculate_impact_score(title: str) -> int:
|
||||||
|
score = 1
|
||||||
|
t = (title or "").lower()
|
||||||
|
if "missile" in t:
|
||||||
|
score += 3
|
||||||
|
if "strike" in t:
|
||||||
|
score += 2
|
||||||
|
if "killed" in t or "death" in t or "casualt" in t:
|
||||||
|
score += 4
|
||||||
|
if "troops" in t or "soldier" in t:
|
||||||
|
score += 2
|
||||||
|
if "attack" in t or "attacked" in t:
|
||||||
|
score += 3
|
||||||
|
if "nuclear" in t or "核" in t:
|
||||||
|
score += 4
|
||||||
|
if "explosion" in t or "blast" in t or "bomb" in t:
|
||||||
|
score += 2
|
||||||
|
return min(score, 10)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 获取 GDELT 实时事件
|
||||||
|
# ==========================
|
||||||
|
def _parse_article(article: dict) -> Optional[dict]:
|
||||||
|
title_raw = article.get("title") or article.get("seendate") or ""
|
||||||
|
if not title_raw:
|
||||||
|
return None
|
||||||
|
from translate_utils import translate_to_chinese
|
||||||
|
from cleaner_ai import clean_news_for_panel
|
||||||
|
title = translate_to_chinese(str(title_raw)[:500])
|
||||||
|
title = clean_news_for_panel(title, max_len=150)
|
||||||
|
url = article.get("url") or article.get("socialimage") or ""
|
||||||
|
seendate = article.get("seendate") or datetime.utcnow().isoformat()
|
||||||
|
lat = article.get("lat")
|
||||||
|
lng = article.get("lng")
|
||||||
|
# 无经纬度时使用伊朗坐标(攻击源)
|
||||||
|
if lat is None or lng is None:
|
||||||
|
lat, lng = IRAN_COORD[1], IRAN_COORD[0]
|
||||||
|
try:
|
||||||
|
lat, lng = float(lat), float(lng)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
lat, lng = IRAN_COORD[1], IRAN_COORD[0]
|
||||||
|
impact = calculate_impact_score(title_raw)
|
||||||
|
event_id = hashlib.sha256(f"{url}{seendate}".encode()).hexdigest()[:24]
|
||||||
|
return {
|
||||||
|
"event_id": event_id,
|
||||||
|
"event_time": seendate,
|
||||||
|
"title": title[:500],
|
||||||
|
"lat": lat,
|
||||||
|
"lng": lng,
|
||||||
|
"impact_score": impact,
|
||||||
|
"url": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_gdelt_events() -> None:
|
||||||
|
if GDELT_DISABLED:
|
||||||
|
return
|
||||||
|
url = (
|
||||||
|
"https://api.gdeltproject.org/api/v2/doc/doc"
|
||||||
|
f"?query={QUERY}"
|
||||||
|
"&mode=ArtList"
|
||||||
|
"&format=json"
|
||||||
|
f"&maxrecords={MAX_RECORDS}"
|
||||||
|
f"×pan={GDELT_TIMESPAN}"
|
||||||
|
"&sort=datedesc"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, **_REQ_KW)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
articles = data.get("articles", data) if isinstance(data, dict) else (data if isinstance(data, list) else [])
|
||||||
|
if not isinstance(articles, list):
|
||||||
|
articles = []
|
||||||
|
new_events = []
|
||||||
|
for a in articles:
|
||||||
|
ev = _parse_article(a) if isinstance(a, dict) else None
|
||||||
|
if ev:
|
||||||
|
new_events.append(ev)
|
||||||
|
# 按 event_time 排序,最新在前
|
||||||
|
new_events.sort(key=lambda e: e.get("event_time", ""), reverse=True)
|
||||||
|
global EVENT_CACHE
|
||||||
|
EVENT_CACHE = new_events
|
||||||
|
# 写入 SQLite 并通知 Node
|
||||||
|
_write_to_db(new_events)
|
||||||
|
_notify_node()
|
||||||
|
print(f"[{datetime.now().strftime('%H:%M:%S')}] GDELT 更新 {len(new_events)} 条事件")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_table(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS gdelt_events (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
event_time TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lng REAL NOT NULL,
|
||||||
|
impact_score INTEGER NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS conflict_stats (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
total_events INTEGER NOT NULL,
|
||||||
|
high_impact_events INTEGER NOT NULL,
|
||||||
|
estimated_casualties INTEGER NOT NULL,
|
||||||
|
estimated_strike_count INTEGER NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_to_db(events: List[dict]) -> None:
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
return
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
|
try:
|
||||||
|
_ensure_table(conn)
|
||||||
|
for e in events:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO gdelt_events (event_id, event_time, title, lat, lng, impact_score, url) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
e["event_id"],
|
||||||
|
e.get("event_time", ""),
|
||||||
|
e.get("title", ""),
|
||||||
|
e.get("lat", 0),
|
||||||
|
e.get("lng", 0),
|
||||||
|
e.get("impact_score", 1),
|
||||||
|
e.get("url", ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# 战损统计模型(展示用)
|
||||||
|
high = sum(1 for x in events if x.get("impact_score", 0) >= 7)
|
||||||
|
strikes = sum(1 for x in events if "strike" in (x.get("title") or "").lower() or "attack" in (x.get("title") or "").lower())
|
||||||
|
casualties = min(5000, high * 80 + len(events) * 10) # 估算
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO conflict_stats (id, total_events, high_impact_events, estimated_casualties, estimated_strike_count, updated_at) VALUES (1, ?, ?, ?, ?, ?)",
|
||||||
|
(len(events), high, casualties, strikes, datetime.utcnow().isoformat()),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)",
|
||||||
|
(datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"),),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"写入 DB 失败: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_node() -> None:
|
||||||
|
try:
|
||||||
|
r = requests.post(f"{API_BASE}/api/crawler/notify", timeout=5, proxies={"http": None, "https": None})
|
||||||
|
if r.status_code != 200:
|
||||||
|
print(" [warn] notify API 失败")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [warn] notify API: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# RSS 新闻抓取(补充 situation_update + AI 提取面板数据)
|
||||||
|
# ==========================
|
||||||
|
LAST_FETCH = {"items": 0, "inserted": 0, "error": None}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_news() -> None:
|
||||||
|
try:
|
||||||
|
from scrapers.rss_scraper import fetch_all
|
||||||
|
from db_writer import write_updates
|
||||||
|
from translate_utils import translate_to_chinese
|
||||||
|
from cleaner_ai import clean_news_for_panel
|
||||||
|
from cleaner_ai import ensure_category, ensure_severity
|
||||||
|
LAST_FETCH["error"] = None
|
||||||
|
items = fetch_all()
|
||||||
|
for it in items:
|
||||||
|
raw_title = translate_to_chinese(it.get("title", "") or "")
|
||||||
|
raw_summary = translate_to_chinese(it.get("summary", "") or it.get("title", ""))
|
||||||
|
it["title"] = clean_news_for_panel(raw_title, max_len=80)
|
||||||
|
it["summary"] = clean_news_for_panel(raw_summary or raw_title, max_len=120)
|
||||||
|
it["category"] = ensure_category(it.get("category", "other"))
|
||||||
|
it["severity"] = ensure_severity(it.get("severity", "medium"))
|
||||||
|
n = write_updates(items) if items else 0
|
||||||
|
LAST_FETCH["items"] = len(items)
|
||||||
|
LAST_FETCH["inserted"] = n
|
||||||
|
if items:
|
||||||
|
_extract_and_merge_panel_data(items)
|
||||||
|
if n > 0:
|
||||||
|
_notify_node()
|
||||||
|
print(f"[{datetime.now().strftime('%H:%M:%S')}] RSS 抓取 {len(items)} 条,新增入库 {n} 条")
|
||||||
|
except Exception as e:
|
||||||
|
LAST_FETCH["error"] = str(e)
|
||||||
|
print(f"[{datetime.now().strftime('%H:%M:%S')}] 新闻抓取失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_and_merge_panel_data(items: list) -> None:
|
||||||
|
"""对新闻做 AI/规则 提取,合并到 combat_losses / retaliation / wall_street_trend 等表"""
|
||||||
|
if not items or not os.path.exists(DB_PATH):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from db_merge import merge
|
||||||
|
if os.environ.get("CLEANER_AI_DISABLED", "0") == "1":
|
||||||
|
from extractor_rules import extract_from_news
|
||||||
|
else:
|
||||||
|
from extractor_ai import extract_from_news
|
||||||
|
from datetime import timezone
|
||||||
|
merged_any = False
|
||||||
|
# 只对前几条有足够文本的新闻做提取,避免 Ollama 调用过多
|
||||||
|
for it in items[:5]:
|
||||||
|
text = (it.get("title", "") or "") + " " + (it.get("summary", "") or "")
|
||||||
|
if len(text.strip()) < 20:
|
||||||
|
continue
|
||||||
|
pub = it.get("published")
|
||||||
|
ts = None
|
||||||
|
if pub:
|
||||||
|
try:
|
||||||
|
if isinstance(pub, str):
|
||||||
|
pub_dt = datetime.fromisoformat(pub.replace("Z", "+00:00"))
|
||||||
|
else:
|
||||||
|
pub_dt = pub
|
||||||
|
if pub_dt.tzinfo:
|
||||||
|
pub_dt = pub_dt.astimezone(timezone.utc)
|
||||||
|
ts = pub_dt.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
extracted = extract_from_news(text, timestamp=ts)
|
||||||
|
if extracted:
|
||||||
|
if merge(extracted, db_path=DB_PATH):
|
||||||
|
merged_any = True
|
||||||
|
if merged_any:
|
||||||
|
_notify_node()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [warn] AI 面板数据提取/合并: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 定时任务(RSS 更频繁,优先保证事件脉络实时)
|
||||||
|
# ==========================
|
||||||
|
scheduler = BackgroundScheduler()
|
||||||
|
scheduler.add_job(fetch_news, "interval", seconds=RSS_INTERVAL_SEC, max_instances=2, coalesce=True)
|
||||||
|
scheduler.add_job(fetch_gdelt_events, "interval", seconds=FETCH_INTERVAL_SEC, max_instances=2, coalesce=True)
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# API 接口
|
||||||
|
# ==========================
|
||||||
|
@app.get("/crawler/status")
|
||||||
|
def crawler_status():
|
||||||
|
"""爬虫状态:用于排查数据更新链路"""
|
||||||
|
import os
|
||||||
|
db_ok = os.path.exists(DB_PATH)
|
||||||
|
total = 0
|
||||||
|
if db_ok:
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=3)
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM situation_update").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"db_path": DB_PATH,
|
||||||
|
"db_exists": db_ok,
|
||||||
|
"situation_update_count": total,
|
||||||
|
"last_fetch_items": LAST_FETCH.get("items", 0),
|
||||||
|
"last_fetch_inserted": LAST_FETCH.get("inserted", 0),
|
||||||
|
"last_fetch_error": LAST_FETCH.get("error"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/events")
|
||||||
|
def get_events():
|
||||||
|
return {
|
||||||
|
"updated_at": datetime.utcnow().isoformat(),
|
||||||
|
"count": len(EVENT_CACHE),
|
||||||
|
"events": EVENT_CACHE,
|
||||||
|
"conflict_stats": _get_conflict_stats(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conflict_stats() -> dict:
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
return {"total_events": 0, "high_impact_events": 0, "estimated_casualties": 0, "estimated_strike_count": 0}
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=5)
|
||||||
|
row = conn.execute("SELECT total_events, high_impact_events, estimated_casualties, estimated_strike_count FROM conflict_stats WHERE id = 1").fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row:
|
||||||
|
return {
|
||||||
|
"total_events": row[0],
|
||||||
|
"high_impact_events": row[1],
|
||||||
|
"estimated_casualties": row[2],
|
||||||
|
"estimated_strike_count": row[3],
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"total_events": 0, "high_impact_events": 0, "estimated_casualties": 0, "estimated_strike_count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup():
|
||||||
|
# 新闻优先启动,确保事件脉络有数据
|
||||||
|
fetch_news()
|
||||||
|
fetch_gdelt_events()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
6
crawler/requirements.txt
Normal file
6
crawler/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
requests>=2.31.0
|
||||||
|
feedparser>=6.0.0
|
||||||
|
fastapi>=0.109.0
|
||||||
|
uvicorn>=0.27.0
|
||||||
|
apscheduler>=3.10.0
|
||||||
|
deep-translator>=1.11.0
|
||||||
1
crawler/scrapers/__init__.py
Normal file
1
crawler/scrapers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
BIN
crawler/scrapers/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
crawler/scrapers/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
crawler/scrapers/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
crawler/scrapers/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
crawler/scrapers/__pycache__/rss_scraper.cpython-311.pyc
Normal file
BIN
crawler/scrapers/__pycache__/rss_scraper.cpython-311.pyc
Normal file
Binary file not shown.
BIN
crawler/scrapers/__pycache__/rss_scraper.cpython-39.pyc
Normal file
BIN
crawler/scrapers/__pycache__/rss_scraper.cpython-39.pyc
Normal file
Binary file not shown.
76
crawler/scrapers/rss_scraper.py
Normal file
76
crawler/scrapers/rss_scraper.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""RSS 抓取"""
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import feedparser
|
||||||
|
|
||||||
|
from config import RSS_FEEDS, KEYWORDS
|
||||||
|
from parser_ai import classify_and_severity
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(entry) -> datetime:
|
||||||
|
for attr in ("published_parsed", "updated_parsed"):
|
||||||
|
val = getattr(entry, attr, None)
|
||||||
|
if val:
|
||||||
|
try:
|
||||||
|
return datetime(*val[:6], tzinfo=timezone.utc)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_html(s: str) -> str:
|
||||||
|
return re.sub(r"<[^>]+>", "", s) if s else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_keywords(text: str) -> bool:
|
||||||
|
t = (text or "").lower()
|
||||||
|
for k in KEYWORDS:
|
||||||
|
if k.lower() in t:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_all() -> list[dict]:
|
||||||
|
import socket
|
||||||
|
items: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
# 单源超时 10 秒,避免某源卡住
|
||||||
|
old_timeout = socket.getdefaulttimeout()
|
||||||
|
socket.setdefaulttimeout(10)
|
||||||
|
try:
|
||||||
|
for url in RSS_FEEDS:
|
||||||
|
try:
|
||||||
|
feed = feedparser.parse(
|
||||||
|
url,
|
||||||
|
request_headers={"User-Agent": "US-Iran-Dashboard/1.0"},
|
||||||
|
agent="US-Iran-Dashboard/1.0",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for entry in feed.entries:
|
||||||
|
title = getattr(entry, "title", "") or ""
|
||||||
|
raw_summary = getattr(entry, "summary", "") or getattr(entry, "description", "") or ""
|
||||||
|
summary = _strip_html(raw_summary)
|
||||||
|
link = getattr(entry, "link", "") or ""
|
||||||
|
text = f"{title} {summary}"
|
||||||
|
if not _matches_keywords(text):
|
||||||
|
continue
|
||||||
|
key = (title[:80], link)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
published = _parse_date(entry)
|
||||||
|
cat, sev = classify_and_severity(text)
|
||||||
|
items.append({
|
||||||
|
"title": title,
|
||||||
|
"summary": summary[:400] if summary else title,
|
||||||
|
"url": link,
|
||||||
|
"published": _parse_date(entry),
|
||||||
|
"category": cat,
|
||||||
|
"severity": sev,
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
socket.setdefaulttimeout(old_timeout)
|
||||||
|
return items
|
||||||
38
crawler/translate_utils.py
Normal file
38
crawler/translate_utils.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""英译中,入库前统一翻译"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _is_mostly_chinese(text: str) -> bool:
|
||||||
|
if not text or len(text.strip()) < 2:
|
||||||
|
return False
|
||||||
|
chinese = len(re.findall(r"[\u4e00-\u9fff]", text))
|
||||||
|
return chinese / max(len(text), 1) > 0.3
|
||||||
|
|
||||||
|
|
||||||
|
def translate_to_chinese(text: str) -> str:
|
||||||
|
"""将文本翻译成中文,失败或已是中文则返回原文。Google 失败时尝试 MyMemory。"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return text
|
||||||
|
if os.environ.get("TRANSLATE_DISABLED", "0") == "1":
|
||||||
|
return text
|
||||||
|
s = str(text).strip()
|
||||||
|
if len(s) > 2000:
|
||||||
|
s = s[:2000]
|
||||||
|
if _is_mostly_chinese(s):
|
||||||
|
return text
|
||||||
|
for translator in ["google", "mymemory"]:
|
||||||
|
try:
|
||||||
|
if translator == "google":
|
||||||
|
from deep_translator import GoogleTranslator
|
||||||
|
out = GoogleTranslator(source="auto", target="zh-CN").translate(s)
|
||||||
|
else:
|
||||||
|
from deep_translator import MyMemoryTranslator
|
||||||
|
out = MyMemoryTranslator(source="auto", target="zh-CN").translate(s)
|
||||||
|
if out and out.strip() and out != s:
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return text
|
||||||
91
docs/BACKEND_MODULES.md
Normal file
91
docs/BACKEND_MODULES.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# 后端模块说明
|
||||||
|
|
||||||
|
## 一、现有模块结构
|
||||||
|
|
||||||
|
```
|
||||||
|
server/
|
||||||
|
├── index.js # HTTP + WebSocket 入口
|
||||||
|
├── routes.js # REST API 路由
|
||||||
|
├── db.js # SQLite schema 与连接
|
||||||
|
├── situationData.js # 态势数据聚合 (从 DB 读取)
|
||||||
|
├── seed.js # 初始数据填充
|
||||||
|
├── data.db # SQLite 数据库
|
||||||
|
└── package.json
|
||||||
|
|
||||||
|
crawler/
|
||||||
|
├── realtime_conflict_service.py # GDELT 实时冲突服务 (核心)
|
||||||
|
├── requirements.txt
|
||||||
|
├── config.py, db_writer.py # 旧 RSS 爬虫(可保留)
|
||||||
|
├── main.py
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. server/index.js
|
||||||
|
- Express + CORS
|
||||||
|
- WebSocket (`/ws`),每 5 秒广播 `situation`
|
||||||
|
- `POST /api/crawler/notify`:爬虫写入后触发立即广播
|
||||||
|
|
||||||
|
### 2. server/routes.js
|
||||||
|
- `GET /api/situation`:完整态势
|
||||||
|
- `GET /api/events`:GDELT 事件 + 冲突统计
|
||||||
|
- `GET /api/health`:健康检查
|
||||||
|
|
||||||
|
### 3. server/db.js
|
||||||
|
- 表:`situation`、`force_summary`、`power_index`、`force_asset`、
|
||||||
|
`key_location`、`combat_losses`、`wall_street_trend`、
|
||||||
|
`retaliation_current`、`retaliation_history`、`situation_update`、
|
||||||
|
**`gdelt_events`**、**`conflict_stats`**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、GDELT 核心数据源
|
||||||
|
|
||||||
|
**GDELT Project**:全球冲突数据库,约 15 分钟级更新,含经纬度、事件编码、参与方、事件强度。
|
||||||
|
|
||||||
|
### realtime_conflict_service.py
|
||||||
|
|
||||||
|
- 定时(默认 60 秒)从 GDELT API 抓取
|
||||||
|
- 冲突强度评分:missile +3, strike +2, killed +4 等
|
||||||
|
- 无经纬度时默认攻击源:`IRAN_COORD = [51.3890, 35.6892]`
|
||||||
|
- 写入 `gdelt_events`、`conflict_stats`
|
||||||
|
- 调用 `POST /api/crawler/notify` 触发 Node 广播
|
||||||
|
|
||||||
|
### 冲突强度 → 地图效果
|
||||||
|
|
||||||
|
| impact_score | 效果 |
|
||||||
|
|--------------|------------|
|
||||||
|
| 1–3 | 绿色点 |
|
||||||
|
| 4–6 | 橙色闪烁 |
|
||||||
|
| 7–10 | 红色脉冲扩散 |
|
||||||
|
|
||||||
|
### 战损统计模型(展示用)
|
||||||
|
|
||||||
|
- `total_events`
|
||||||
|
- `high_impact_events` (impact ≥ 7)
|
||||||
|
- `estimated_casualties`
|
||||||
|
- `estimated_strike_count`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
GDELT API → Python 服务(60s) → gdelt_events, conflict_stats
|
||||||
|
↓
|
||||||
|
POST /api/crawler/notify → situation.updated_at
|
||||||
|
↓
|
||||||
|
WebSocket 广播 getSituation() → 前端
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、运行方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 启动 Node API
|
||||||
|
npm run api
|
||||||
|
|
||||||
|
# 2. 启动 GDELT 服务
|
||||||
|
npm run gdelt
|
||||||
|
# 或: cd crawler && uvicorn realtime_conflict_service:app --port 8000
|
||||||
|
```
|
||||||
54
package-lock.json
generated
54
package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-map-gl": "^7.1.7",
|
"react-map-gl": "^7.1.7",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
@@ -4396,6 +4397,54 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.13.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/react-router/-/react-router-7.13.1.tgz",
|
||||||
|
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.13.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||||
|
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.13.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router/node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -4645,6 +4694,11 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||||
|
},
|
||||||
"node_modules/set-value": {
|
"node_modules/set-value": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/set-value/-/set-value-2.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/set-value/-/set-value-2.0.1.tgz",
|
||||||
|
|||||||
@@ -4,9 +4,13 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"start": "./start.sh",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"api": "node server/index.js",
|
"api": "node server/index.js",
|
||||||
"api:seed": "node server/seed.js",
|
"api:seed": "node server/seed.js",
|
||||||
|
"crawler": "cd crawler && python main.py",
|
||||||
|
"gdelt": "cd crawler && uvicorn realtime_conflict_service:app --host 0.0.0.0 --port 8000",
|
||||||
|
"crawler:test": "cd crawler && python3 -c \"import sys; sys.path.insert(0,'.'); from scrapers.rss_scraper import fetch_all; n=len(fetch_all()); print('RSS 抓取:', n, '条' if n else '(0 条,检查网络或关键词过滤)')\"",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
@@ -23,6 +27,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-map-gl": "^7.1.7",
|
"react-map-gl": "^7.1.7",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
43
server/db.js
43
server/db.js
@@ -92,6 +92,26 @@ db.exec(`
|
|||||||
summary TEXT NOT NULL,
|
summary TEXT NOT NULL,
|
||||||
severity TEXT NOT NULL
|
severity TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS gdelt_events (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
event_time TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lng REAL NOT NULL,
|
||||||
|
impact_score INTEGER NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS conflict_stats (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
total_events INTEGER NOT NULL DEFAULT 0,
|
||||||
|
high_impact_events INTEGER NOT NULL DEFAULT 0,
|
||||||
|
estimated_casualties INTEGER NOT NULL DEFAULT 0,
|
||||||
|
estimated_strike_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// 迁移:为已有 key_location 表添加 type、region、status、damage_level 列
|
// 迁移:为已有 key_location 表添加 type、region、status、damage_level 列
|
||||||
@@ -103,5 +123,28 @@ try {
|
|||||||
if (!names.includes('status')) db.exec('ALTER TABLE key_location ADD COLUMN status TEXT DEFAULT "operational"')
|
if (!names.includes('status')) db.exec('ALTER TABLE key_location ADD COLUMN status TEXT DEFAULT "operational"')
|
||||||
if (!names.includes('damage_level')) db.exec('ALTER TABLE key_location ADD COLUMN damage_level INTEGER')
|
if (!names.includes('damage_level')) db.exec('ALTER TABLE key_location ADD COLUMN damage_level INTEGER')
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
// 迁移:combat_losses 添加平民伤亡、updated_at
|
||||||
|
try {
|
||||||
|
const lossCols = db.prepare('PRAGMA table_info(combat_losses)').all()
|
||||||
|
const lossNames = lossCols.map((c) => c.name)
|
||||||
|
if (!lossNames.includes('civilian_killed')) db.exec('ALTER TABLE combat_losses ADD COLUMN civilian_killed INTEGER NOT NULL DEFAULT 0')
|
||||||
|
if (!lossNames.includes('civilian_wounded')) db.exec('ALTER TABLE combat_losses ADD COLUMN civilian_wounded INTEGER NOT NULL DEFAULT 0')
|
||||||
|
if (!lossNames.includes('updated_at')) db.exec('ALTER TABLE combat_losses ADD COLUMN updated_at TEXT DEFAULT (datetime("now"))')
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// 迁移:所有表添加 updated_at 用于数据回放
|
||||||
|
const addUpdatedAt = (table) => {
|
||||||
|
try {
|
||||||
|
const cols = db.prepare(`PRAGMA table_info(${table})`).all()
|
||||||
|
if (!cols.some((c) => c.name === 'updated_at')) {
|
||||||
|
db.exec(`ALTER TABLE ${table} ADD COLUMN updated_at TEXT DEFAULT (datetime("now"))`)
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
addUpdatedAt('force_summary')
|
||||||
|
addUpdatedAt('power_index')
|
||||||
|
addUpdatedAt('force_asset')
|
||||||
|
addUpdatedAt('key_location')
|
||||||
|
addUpdatedAt('retaliation_current')
|
||||||
|
|
||||||
module.exports = db
|
module.exports = db
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ app.use(cors())
|
|||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
app.use('/api', routes)
|
app.use('/api', routes)
|
||||||
app.get('/api/health', (_, res) => res.json({ ok: true }))
|
app.get('/api/health', (_, res) => res.json({ ok: true }))
|
||||||
|
app.post('/api/crawler/notify', (_, res) => {
|
||||||
|
notifyCrawlerUpdate()
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
const server = http.createServer(app)
|
const server = http.createServer(app)
|
||||||
|
|
||||||
@@ -27,7 +31,16 @@ function broadcastSituation() {
|
|||||||
})
|
})
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
setInterval(broadcastSituation, 5000)
|
setInterval(broadcastSituation, 3000)
|
||||||
|
|
||||||
|
// 供爬虫调用:更新 situation.updated_at 并立即广播
|
||||||
|
function notifyCrawlerUpdate() {
|
||||||
|
try {
|
||||||
|
const db = require('./db')
|
||||||
|
db.prepare("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)").run(new Date().toISOString())
|
||||||
|
broadcastSituation()
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`API + WebSocket running at http://localhost:${PORT}`)
|
console.log(`API + WebSocket running at http://localhost:${PORT}`)
|
||||||
|
|||||||
@@ -1,8 +1,58 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
const { getSituation } = require('./situationData')
|
const { getSituation } = require('./situationData')
|
||||||
|
const db = require('./db')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 数据库 Dashboard:返回各表原始数据
|
||||||
|
router.get('/db/dashboard', (req, res) => {
|
||||||
|
try {
|
||||||
|
const tables = [
|
||||||
|
'situation',
|
||||||
|
'force_summary',
|
||||||
|
'power_index',
|
||||||
|
'force_asset',
|
||||||
|
'key_location',
|
||||||
|
'combat_losses',
|
||||||
|
'wall_street_trend',
|
||||||
|
'retaliation_current',
|
||||||
|
'retaliation_history',
|
||||||
|
'situation_update',
|
||||||
|
'gdelt_events',
|
||||||
|
'conflict_stats',
|
||||||
|
]
|
||||||
|
const data = {}
|
||||||
|
const timeSort = {
|
||||||
|
situation: 'updated_at DESC',
|
||||||
|
situation_update: 'timestamp DESC',
|
||||||
|
gdelt_events: 'event_time DESC',
|
||||||
|
wall_street_trend: 'time DESC',
|
||||||
|
retaliation_history: 'time DESC',
|
||||||
|
conflict_stats: 'updated_at DESC',
|
||||||
|
}
|
||||||
|
for (const name of tables) {
|
||||||
|
try {
|
||||||
|
const order = timeSort[name]
|
||||||
|
let rows
|
||||||
|
try {
|
||||||
|
rows = order
|
||||||
|
? db.prepare(`SELECT * FROM ${name} ORDER BY ${order}`).all()
|
||||||
|
: db.prepare(`SELECT * FROM ${name}`).all()
|
||||||
|
} catch (qerr) {
|
||||||
|
rows = db.prepare(`SELECT * FROM ${name}`).all()
|
||||||
|
}
|
||||||
|
data[name] = rows
|
||||||
|
} catch (e) {
|
||||||
|
data[name] = { error: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
router.get('/situation', (req, res) => {
|
router.get('/situation', (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(getSituation())
|
res.json(getSituation())
|
||||||
@@ -12,4 +62,19 @@ router.get('/situation', (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/events', (req, res) => {
|
||||||
|
try {
|
||||||
|
const s = getSituation()
|
||||||
|
res.json({
|
||||||
|
updated_at: s.lastUpdated,
|
||||||
|
count: (s.conflictEvents || []).length,
|
||||||
|
events: s.conflictEvents || [],
|
||||||
|
conflict_stats: s.conflictStats || {},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
res.status(500).json({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -140,11 +140,19 @@ function seed() {
|
|||||||
]
|
]
|
||||||
iranLocs.forEach((r) => insertLoc.run(...r))
|
iranLocs.forEach((r) => insertLoc.run(...r))
|
||||||
|
|
||||||
db.exec(`
|
try {
|
||||||
INSERT OR REPLACE INTO combat_losses (side, bases_destroyed, bases_damaged, personnel_killed, personnel_wounded, aircraft, warships, armor, vehicles) VALUES
|
db.exec(`
|
||||||
('us', 0, 27, 127, 384, 2, 0, 0, 8),
|
INSERT OR REPLACE INTO combat_losses (side, bases_destroyed, bases_damaged, personnel_killed, personnel_wounded, civilian_killed, civilian_wounded, aircraft, warships, armor, vehicles) VALUES
|
||||||
('iran', 3, 8, 2847, 5620, 24, 12, 18, 42);
|
('us', 0, 27, 127, 384, 18, 52, 2, 0, 0, 8),
|
||||||
`)
|
('iran', 3, 8, 2847, 5620, 412, 1203, 24, 12, 18, 42);
|
||||||
|
`)
|
||||||
|
} catch (_) {
|
||||||
|
db.exec(`
|
||||||
|
INSERT OR REPLACE INTO combat_losses (side, bases_destroyed, bases_damaged, personnel_killed, personnel_wounded, aircraft, warships, armor, vehicles) VALUES
|
||||||
|
('us', 0, 27, 127, 384, 2, 0, 0, 8),
|
||||||
|
('iran', 3, 8, 2847, 5620, 24, 12, 18, 42);
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
db.exec('DELETE FROM wall_street_trend')
|
db.exec('DELETE FROM wall_street_trend')
|
||||||
const trendRows = [['2025-03-01T00:00:00', 82], ['2025-03-01T03:00:00', 85], ['2025-03-01T06:00:00', 88], ['2025-03-01T09:00:00', 90], ['2025-03-01T12:00:00', 92], ['2025-03-01T15:00:00', 94], ['2025-03-01T18:00:00', 95], ['2025-03-01T21:00:00', 96], ['2025-03-01T23:00:00', 98]]
|
const trendRows = [['2025-03-01T00:00:00', 82], ['2025-03-01T03:00:00', 85], ['2025-03-01T06:00:00', 88], ['2025-03-01T09:00:00', 90], ['2025-03-01T12:00:00', 92], ['2025-03-01T15:00:00', 94], ['2025-03-01T18:00:00', 95], ['2025-03-01T21:00:00', 96], ['2025-03-01T23:00:00', 98]]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function toLosses(row) {
|
|||||||
return {
|
return {
|
||||||
bases: { destroyed: row.bases_destroyed, damaged: row.bases_damaged },
|
bases: { destroyed: row.bases_destroyed, damaged: row.bases_damaged },
|
||||||
personnelCasualties: { killed: row.personnel_killed, wounded: row.personnel_wounded },
|
personnelCasualties: { killed: row.personnel_killed, wounded: row.personnel_wounded },
|
||||||
|
civilianCasualties: { killed: row.civilian_killed ?? 0, wounded: row.civilian_wounded ?? 0 },
|
||||||
aircraft: row.aircraft,
|
aircraft: row.aircraft,
|
||||||
warships: row.warships,
|
warships: row.warships,
|
||||||
armor: row.armor,
|
armor: row.armor,
|
||||||
@@ -25,6 +26,7 @@ function toLosses(row) {
|
|||||||
const defaultLosses = {
|
const defaultLosses = {
|
||||||
bases: { destroyed: 0, damaged: 0 },
|
bases: { destroyed: 0, damaged: 0 },
|
||||||
personnelCasualties: { killed: 0, wounded: 0 },
|
personnelCasualties: { killed: 0, wounded: 0 },
|
||||||
|
civilianCasualties: { killed: 0, wounded: 0 },
|
||||||
aircraft: 0,
|
aircraft: 0,
|
||||||
warships: 0,
|
warships: 0,
|
||||||
armor: 0,
|
armor: 0,
|
||||||
@@ -45,9 +47,35 @@ function getSituation() {
|
|||||||
const trend = db.prepare('SELECT time, value FROM wall_street_trend ORDER BY time').all()
|
const trend = db.prepare('SELECT time, value FROM wall_street_trend ORDER BY time').all()
|
||||||
const retaliationCur = db.prepare('SELECT value FROM retaliation_current WHERE id = 1').get()
|
const retaliationCur = db.prepare('SELECT value FROM retaliation_current WHERE id = 1').get()
|
||||||
const retaliationHist = db.prepare('SELECT time, value FROM retaliation_history ORDER BY time').all()
|
const retaliationHist = db.prepare('SELECT time, value FROM retaliation_history ORDER BY time').all()
|
||||||
const updates = db.prepare('SELECT * FROM situation_update ORDER BY timestamp DESC').all()
|
const updates = db.prepare('SELECT * FROM situation_update ORDER BY timestamp DESC LIMIT 50').all()
|
||||||
const meta = db.prepare('SELECT updated_at FROM situation WHERE id = 1').get()
|
const meta = db.prepare('SELECT updated_at FROM situation WHERE id = 1').get()
|
||||||
|
|
||||||
|
let conflictEvents = []
|
||||||
|
let conflictStats = { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 }
|
||||||
|
try {
|
||||||
|
conflictEvents = db.prepare('SELECT event_id, event_time, title, lat, lng, impact_score, url FROM gdelt_events ORDER BY event_time DESC LIMIT 30').all()
|
||||||
|
const statsRow = db.prepare('SELECT total_events, high_impact_events, estimated_casualties, estimated_strike_count FROM conflict_stats WHERE id = 1').get()
|
||||||
|
if (statsRow) conflictStats = statsRow
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// 平民伤亡:合计显示,不区分阵营
|
||||||
|
const civUsK = lossesUs?.civilian_killed ?? 0
|
||||||
|
const civUsW = lossesUs?.civilian_wounded ?? 0
|
||||||
|
const civIrK = lossesIr?.civilian_killed ?? 0
|
||||||
|
const civIrW = lossesIr?.civilian_wounded ?? 0
|
||||||
|
const dbKilled = civUsK + civIrK
|
||||||
|
const dbWounded = civUsW + civIrW
|
||||||
|
const est = conflictStats.estimated_casualties || 0
|
||||||
|
const civilianCasualtiesTotal = {
|
||||||
|
killed: est > 0 ? Math.max(dbKilled, est) : dbKilled,
|
||||||
|
wounded: dbWounded,
|
||||||
|
}
|
||||||
|
|
||||||
|
const usLossesBase = lossesUs ? toLosses(lossesUs) : defaultLosses
|
||||||
|
const irLossesBase = lossesIr ? toLosses(lossesIr) : defaultLosses
|
||||||
|
const usLosses = { ...usLossesBase, civilianCasualties: { killed: 0, wounded: 0 } }
|
||||||
|
const irLosses = { ...irLossesBase, civilianCasualties: { killed: 0, wounded: 0 } }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lastUpdated: meta?.updated_at || new Date().toISOString(),
|
lastUpdated: meta?.updated_at || new Date().toISOString(),
|
||||||
usForces: {
|
usForces: {
|
||||||
@@ -69,7 +97,7 @@ function getSituation() {
|
|||||||
},
|
},
|
||||||
assets: (assetsUs || []).map(toAsset),
|
assets: (assetsUs || []).map(toAsset),
|
||||||
keyLocations: locUs || [],
|
keyLocations: locUs || [],
|
||||||
combatLosses: lossesUs ? toLosses(lossesUs) : defaultLosses,
|
combatLosses: usLosses,
|
||||||
wallStreetInvestmentTrend: trend || [],
|
wallStreetInvestmentTrend: trend || [],
|
||||||
},
|
},
|
||||||
iranForces: {
|
iranForces: {
|
||||||
@@ -91,7 +119,7 @@ function getSituation() {
|
|||||||
},
|
},
|
||||||
assets: (assetsIr || []).map(toAsset),
|
assets: (assetsIr || []).map(toAsset),
|
||||||
keyLocations: locIr || [],
|
keyLocations: locIr || [],
|
||||||
combatLosses: lossesIr ? toLosses(lossesIr) : defaultLosses,
|
combatLosses: irLosses,
|
||||||
retaliationSentiment: retaliationCur?.value ?? 0,
|
retaliationSentiment: retaliationCur?.value ?? 0,
|
||||||
retaliationSentimentHistory: retaliationHist || [],
|
retaliationSentimentHistory: retaliationHist || [],
|
||||||
},
|
},
|
||||||
@@ -102,6 +130,17 @@ function getSituation() {
|
|||||||
summary: u.summary,
|
summary: u.summary,
|
||||||
severity: u.severity,
|
severity: u.severity,
|
||||||
})),
|
})),
|
||||||
|
conflictEvents: conflictEvents.map((e) => ({
|
||||||
|
event_id: e.event_id,
|
||||||
|
event_time: e.event_time,
|
||||||
|
title: e.title,
|
||||||
|
lat: e.lat,
|
||||||
|
lng: e.lng,
|
||||||
|
impact_score: e.impact_score,
|
||||||
|
url: e.url,
|
||||||
|
})),
|
||||||
|
conflictStats,
|
||||||
|
civilianCasualtiesTotal,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { Routes, Route } from 'react-router-dom'
|
||||||
import { Dashboard } from '@/pages/Dashboard'
|
import { Dashboard } from '@/pages/Dashboard'
|
||||||
|
import { DbDashboard } from '@/pages/DbDashboard'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -6,7 +8,10 @@ function App() {
|
|||||||
className="min-h-screen w-full bg-military-dark overflow-hidden"
|
className="min-h-screen w-full bg-military-dark overflow-hidden"
|
||||||
style={{ background: '#0A0F1C' }}
|
style={{ background: '#0A0F1C' }}
|
||||||
>
|
>
|
||||||
<Dashboard />
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/db" element={<DbDashboard />} />
|
||||||
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { MilitarySituation } from '@/data/mockData'
|
import type { MilitarySituation } from '@/data/mockData'
|
||||||
|
|
||||||
export async function fetchSituation(): Promise<MilitarySituation> {
|
export async function fetchSituation(): Promise<MilitarySituation> {
|
||||||
const res = await fetch('/api/situation')
|
const res = await fetch(`/api/situation?t=${Date.now()}`, { cache: 'no-store' })
|
||||||
if (!res.ok) throw new Error(`API error: ${res.status}`)
|
if (!res.ok) throw new Error(`API error: ${res.status}`)
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
Users,
|
|
||||||
Skull,
|
Skull,
|
||||||
Bandage,
|
Bandage,
|
||||||
Plane,
|
Plane,
|
||||||
@@ -8,108 +7,110 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Car,
|
Car,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
|
UserCircle,
|
||||||
|
Activity,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatMillions } from '@/utils/formatNumber'
|
import { formatMillions } from '@/utils/formatNumber'
|
||||||
import type { CombatLosses } from '@/data/mockData'
|
import type { CombatLosses, ConflictStats } from '@/data/mockData'
|
||||||
|
|
||||||
interface CombatLossesPanelProps {
|
interface CombatLossesPanelProps {
|
||||||
usLosses: CombatLosses
|
usLosses: CombatLosses
|
||||||
iranLosses: CombatLosses
|
iranLosses: CombatLosses
|
||||||
|
conflictStats?: ConflictStats | null
|
||||||
|
/** 平民伤亡合计(不区分阵营) */
|
||||||
|
civilianTotal?: { killed: number; wounded: number }
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOSS_ITEMS: {
|
export function CombatLossesPanel({ usLosses, iranLosses, conflictStats, civilianTotal, className = '' }: CombatLossesPanelProps) {
|
||||||
key: keyof Omit<CombatLosses, 'bases' | 'personnelCasualties'>
|
const civ = civilianTotal ?? { killed: 0, wounded: 0 }
|
||||||
label: string
|
|
||||||
icon: typeof Plane
|
const otherRows = [
|
||||||
iconColor: string
|
{ label: '基地', icon: Building2, iconColor: 'text-amber-500', us: `${usLosses.bases.destroyed}/${usLosses.bases.damaged}`, ir: `${iranLosses.bases.destroyed}/${iranLosses.bases.damaged}` },
|
||||||
}[] = [
|
{ label: '战机', icon: Plane, iconColor: 'text-sky-400', us: usLosses.aircraft, ir: iranLosses.aircraft },
|
||||||
{ key: 'aircraft', label: '战机', icon: Plane, iconColor: 'text-sky-400' },
|
{ label: '战舰', icon: Ship, iconColor: 'text-blue-500', us: usLosses.warships, ir: iranLosses.warships },
|
||||||
{ key: 'warships', label: '战舰', icon: Ship, iconColor: 'text-blue-500' },
|
{ label: '装甲', icon: Shield, iconColor: 'text-emerald-500', us: usLosses.armor, ir: iranLosses.armor },
|
||||||
{ key: 'armor', label: '装甲', icon: Shield, iconColor: 'text-emerald-500' },
|
{ label: '车辆', icon: Car, iconColor: 'text-slate-400', us: usLosses.vehicles, ir: iranLosses.vehicles },
|
||||||
{ key: 'vehicles', label: '车辆', icon: Car, iconColor: 'text-slate-400' },
|
]
|
||||||
]
|
|
||||||
|
|
||||||
export function CombatLossesPanel({ usLosses, iranLosses, className = '' }: CombatLossesPanelProps) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`flex min-h-[220px] max-h-[240px] min-w-0 flex-1 flex-col overflow-hidden rounded border border-military-border bg-military-panel/95 font-orbitron ${className}`}>
|
||||||
className={`
|
<div className="mb-1.5 flex shrink-0 items-center justify-center gap-2 text-[10px] uppercase tracking-wider text-military-text-secondary">
|
||||||
min-w-0 shrink-0 overflow-x-auto overflow-y-hidden border-t border-military-border scrollbar-thin bg-military-panel/95 px-4 py-2 font-orbitron
|
|
||||||
${className}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex items-center gap-1 text-[10px] uppercase tracking-wider text-military-text-secondary">
|
|
||||||
<TrendingDown className="h-2.5 w-2.5 shrink-0 text-amber-400" />
|
<TrendingDown className="h-2.5 w-2.5 shrink-0 text-amber-400" />
|
||||||
战损数据
|
战损
|
||||||
|
{conflictStats && conflictStats.total_events > 0 && (
|
||||||
|
<span className="flex items-center gap-0.5 rounded bg-cyan-950/50 px-1 py-0.5 text-[9px] text-cyan-400">
|
||||||
|
<Activity className="h-2 w-2" />
|
||||||
|
{conflictStats.total_events}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-wrap gap-x-4 gap-y-3 overflow-x-auto text-xs">
|
|
||||||
{/* 基地 */}
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden px-3 pb-2">
|
||||||
<div className="flex shrink-0 min-w-0 flex-col gap-0.5 overflow-visible">
|
{/* 人员伤亡 - 单独容器 */}
|
||||||
<span className="flex shrink-0 items-center gap-1 text-military-text-secondary">
|
<div className="flex shrink-0 flex-col justify-center overflow-hidden rounded-lg border border-red-900/50 bg-red-950/40 px-3 py-2">
|
||||||
<Building2 className="h-3 w-3 shrink-0 text-amber-500" />
|
<div className="mb-1 flex shrink-0 items-center justify-center gap-3 text-[9px] text-military-text-secondary">
|
||||||
基地
|
<span className="flex items-center gap-0.5"><Skull className="h-3 w-3 text-red-500" /> 阵亡</span>
|
||||||
</span>
|
<span className="flex items-center gap-0.5"><Bandage className="h-3 w-3 text-amber-500" /> 受伤</span>
|
||||||
<div className="flex flex-col gap-0.5 tabular-nums">
|
<span className="text-military-text-secondary/70">|</span>
|
||||||
<div className="flex min-w-0 items-baseline gap-1 overflow-hidden" title={`美 毁${usLosses.bases.destroyed} 损${usLosses.bases.damaged}`}>
|
<span>美 : 伊</span>
|
||||||
<span className="shrink-0 text-military-us">美</span>
|
</div>
|
||||||
<span className="truncate">
|
<div className="grid min-w-0 grid-cols-2 gap-x-2 gap-y-1 overflow-hidden text-center tabular-nums sm:gap-x-4">
|
||||||
毁<strong className="text-amber-400">{usLosses.bases.destroyed}</strong>
|
<div className="min-w-0 truncate text-military-us" title={`美: ${formatMillions(usLosses.personnelCasualties.killed)} / ${formatMillions(usLosses.personnelCasualties.wounded)}`}>
|
||||||
损<strong className="text-amber-300">{usLosses.bases.damaged}</strong>
|
<span className="text-base font-bold text-red-500">{formatMillions(usLosses.personnelCasualties.killed)}</span>
|
||||||
</span>
|
<span className="mx-0.5 text-military-text-secondary">/</span>
|
||||||
|
<span className="text-base font-semibold text-amber-500">{formatMillions(usLosses.personnelCasualties.wounded)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 items-baseline gap-1 overflow-hidden" title={`伊 毁${iranLosses.bases.destroyed} 损${iranLosses.bases.damaged}`}>
|
<div className="min-w-0 truncate text-military-iran" title={`伊: ${formatMillions(iranLosses.personnelCasualties.killed)} / ${formatMillions(iranLosses.personnelCasualties.wounded)}`}>
|
||||||
<span className="shrink-0 text-military-iran">伊</span>
|
<span className="text-base font-bold text-red-500">{formatMillions(iranLosses.personnelCasualties.killed)}</span>
|
||||||
<span className="truncate">
|
<span className="mx-0.5 text-military-text-secondary">/</span>
|
||||||
毁<strong className="text-amber-400">{iranLosses.bases.destroyed}</strong>
|
<span className="text-base font-semibold text-amber-500">{formatMillions(iranLosses.personnelCasualties.wounded)}</span>
|
||||||
损<strong className="text-amber-300">{iranLosses.bases.damaged}</strong>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 人员伤亡 */}
|
{/* 平民伤亡:合计显示,不区分阵营 */}
|
||||||
<div className="flex shrink-0 min-w-0 flex-col gap-0.5 overflow-visible">
|
<div className="flex shrink-0 flex-col justify-center overflow-hidden rounded-lg border border-amber-900/50 bg-amber-950/40 px-3 py-2">
|
||||||
<span className="flex shrink-0 items-center gap-1 text-military-text-secondary">
|
<div className="mb-1 flex shrink-0 items-center justify-center gap-2 text-[9px] text-military-text-secondary">
|
||||||
<Users className="h-3 w-3 shrink-0 text-slate-400" />
|
<UserCircle className="h-3 w-3 text-amber-400" />
|
||||||
人员伤亡
|
平民伤亡(合计)
|
||||||
</span>
|
|
||||||
<div className="flex flex-col gap-0.5 tabular-nums">
|
|
||||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1" title={`美 阵亡${formatMillions(usLosses.personnelCasualties.killed)} 受伤${formatMillions(usLosses.personnelCasualties.wounded)}`}>
|
|
||||||
<span className="shrink-0 text-military-us">美</span>
|
|
||||||
<Skull className="h-2.5 w-2.5 shrink-0 text-red-500" />
|
|
||||||
<strong className="text-red-500">{formatMillions(usLosses.personnelCasualties.killed)}</strong>
|
|
||||||
<Bandage className="h-2.5 w-2.5 shrink-0 text-amber-500" />
|
|
||||||
<strong className="text-amber-500">{formatMillions(usLosses.personnelCasualties.wounded)}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1" title={`伊 阵亡${formatMillions(iranLosses.personnelCasualties.killed)} 受伤${formatMillions(iranLosses.personnelCasualties.wounded)}`}>
|
|
||||||
<span className="shrink-0 text-military-iran">伊</span>
|
|
||||||
<Skull className="h-2.5 w-2.5 shrink-0 text-red-500" />
|
|
||||||
<strong className="text-red-500">{formatMillions(iranLosses.personnelCasualties.killed)}</strong>
|
|
||||||
<Bandage className="h-2.5 w-2.5 shrink-0 text-amber-500" />
|
|
||||||
<strong className="text-amber-500">{formatMillions(iranLosses.personnelCasualties.wounded)}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center justify-center gap-3 text-center tabular-nums">
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
{/* 战机 / 战舰 / 装甲 / 车辆 */}
|
<Skull className="h-3 w-3 text-red-500" />
|
||||||
{LOSS_ITEMS.map(({ key, label, icon: Icon, iconColor }) => (
|
<span className="text-base font-bold text-red-500">{formatMillions(civ.killed)}</span>
|
||||||
<div key={key} className="flex shrink-0 min-w-0 flex-col gap-0.5 overflow-visible">
|
</span>
|
||||||
<span className="flex shrink-0 items-center gap-1 text-military-text-secondary">
|
<span className="text-military-text-secondary/60">/</span>
|
||||||
<Icon className={`h-3 w-3 shrink-0 ${iconColor}`} />
|
<span className="flex items-center gap-0.5">
|
||||||
{label}
|
<Bandage className="h-3 w-3 text-amber-500" />
|
||||||
|
<span className="text-base font-semibold text-amber-500">{formatMillions(civ.wounded)}</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col gap-0.5 tabular-nums">
|
|
||||||
<div className="flex min-w-0 items-baseline gap-1">
|
|
||||||
<span className="shrink-0 text-military-us">美</span>
|
|
||||||
<strong>{usLosses[key]}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 items-baseline gap-1">
|
|
||||||
<span className="shrink-0 text-military-iran">伊</span>
|
|
||||||
<strong>{iranLosses[key]}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
|
||||||
|
{/* 其它 - 标签+图标+数字,单独容器 */}
|
||||||
|
<div className="min-h-0 min-w-0 flex-1 overflow-hidden rounded border border-military-border/50 bg-military-dark/30 px-2 py-1.5">
|
||||||
|
<div className="mb-1 text-[8px] text-military-text-secondary">美:伊</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 overflow-hidden text-[11px] tabular-nums lg:grid-cols-3">
|
||||||
|
{otherRows.map(({ label, icon: Icon, iconColor, ...rest }, i) => (
|
||||||
|
<div key={i} className="flex min-w-0 items-center justify-between gap-1 overflow-hidden">
|
||||||
|
<span className="flex shrink-0 items-center gap-0.5 text-military-text-primary">
|
||||||
|
<Icon className={`h-3 w-3 ${iconColor}`} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{'value' in rest ? (
|
||||||
|
<span className="min-w-0 truncate text-right text-amber-400">{rest.value}</span>
|
||||||
|
) : (
|
||||||
|
<span className="min-w-0 truncate text-right">
|
||||||
|
<span className="text-military-us">{rest.us}</span>
|
||||||
|
<span className="text-military-text-secondary/60">:</span>
|
||||||
|
<span className="text-military-iran">{rest.ir}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
105
src/components/EventTimelinePanel.tsx
Normal file
105
src/components/EventTimelinePanel.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import type { SituationUpdate, ConflictEvent } from '@/data/mockData'
|
||||||
|
import { History, RefreshCw } from 'lucide-react'
|
||||||
|
import { fetchAndSetSituation } from '@/store/situationStore'
|
||||||
|
|
||||||
|
interface EventTimelinePanelProps {
|
||||||
|
updates: SituationUpdate[]
|
||||||
|
conflictEvents?: ConflictEvent[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAT_LABELS: Record<string, string> = {
|
||||||
|
deployment: '部署',
|
||||||
|
alert: '警报',
|
||||||
|
intel: '情报',
|
||||||
|
diplomatic: '外交',
|
||||||
|
other: '其他',
|
||||||
|
}
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimelineItem = {
|
||||||
|
id: string
|
||||||
|
summary: string
|
||||||
|
timestamp: string
|
||||||
|
source: 'gdelt' | 'rss'
|
||||||
|
category?: string
|
||||||
|
severity?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventTimelinePanel({ updates = [], conflictEvents = [], className = '' }: EventTimelinePanelProps) {
|
||||||
|
const [refreshing, setRefreshing] = React.useState(false)
|
||||||
|
const handleRefresh = React.useCallback(async () => {
|
||||||
|
setRefreshing(true)
|
||||||
|
await fetchAndSetSituation().finally(() => setRefreshing(false))
|
||||||
|
}, [])
|
||||||
|
// 合并 GDELT + RSS,按时间倒序(最新在前)
|
||||||
|
const merged: TimelineItem[] = [
|
||||||
|
...(conflictEvents || []).map((e) => ({
|
||||||
|
id: e.event_id,
|
||||||
|
summary: e.title,
|
||||||
|
timestamp: e.event_time,
|
||||||
|
source: 'gdelt' as const,
|
||||||
|
category: 'alert',
|
||||||
|
severity: e.impact_score >= 7 ? 'high' : e.impact_score >= 4 ? 'medium' : 'low',
|
||||||
|
})),
|
||||||
|
...(updates || []).map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
summary: u.summary,
|
||||||
|
timestamp: u.timestamp,
|
||||||
|
source: 'rss' as const,
|
||||||
|
category: u.category,
|
||||||
|
severity: u.severity,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||||
|
.slice(0, 8)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex min-w-0 max-w-[280px] shrink flex-col overflow-hidden rounded border border-military-border bg-military-panel/80 ${className}`}>
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-military-border px-3 py-1.5">
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-military-text-secondary">
|
||||||
|
<History className="h-3.5 w-3.5 shrink-0 text-military-accent" />
|
||||||
|
事件脉络
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="flex items-center gap-1 rounded p-0.5 text-[8px] text-military-text-secondary/80 hover:bg-military-border/30 hover:text-cyan-400 disabled:opacity-50"
|
||||||
|
title="刷新"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3 w-3 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 max-h-[140px] flex-1 overflow-y-auto overflow-x-hidden px-2 py-1">
|
||||||
|
{merged.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-[11px] text-military-text-secondary">暂无事件</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{merged.map((item) => (
|
||||||
|
<li key={item.id} className="flex gap-2 border-b border-military-border/50 pb-2 last:border-0 last:pb-0">
|
||||||
|
<span className="shrink-0 pt-0.5">
|
||||||
|
<span className={`inline-block h-1.5 w-1.5 rounded-full ${item.source === 'gdelt' ? 'bg-cyan-500' : 'bg-amber-500'}`} />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 text-right">
|
||||||
|
<p className="text-[11px] leading-tight text-military-text-primary line-clamp-2">{item.summary}</p>
|
||||||
|
<span className="mt-0.5 flex items-center justify-end gap-1 text-[9px] text-military-text-secondary">
|
||||||
|
{formatTime(item.timestamp)}
|
||||||
|
<span className="text-military-text-secondary/60">
|
||||||
|
{item.source === 'gdelt' ? 'GDELT' : CAT_LABELS[item.category ?? ''] ?? '新闻'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { StatCard } from './StatCard'
|
import { StatCard } from './StatCard'
|
||||||
import { useSituationStore } from '@/store/situationStore'
|
import { useSituationStore } from '@/store/situationStore'
|
||||||
import { Wifi, WifiOff, Clock } from 'lucide-react'
|
import { useReplaySituation } from '@/hooks/useReplaySituation'
|
||||||
|
import { usePlaybackStore } from '@/store/playbackStore'
|
||||||
|
import { Wifi, WifiOff, Clock, Database } from 'lucide-react'
|
||||||
|
|
||||||
export function HeaderPanel() {
|
export function HeaderPanel() {
|
||||||
const { situation, isConnected } = useSituationStore()
|
const situation = useReplaySituation()
|
||||||
|
const isConnected = useSituationStore((s) => s.isConnected)
|
||||||
|
const isReplayMode = usePlaybackStore((s) => s.isReplayMode)
|
||||||
const { usForces, iranForces } = situation
|
const { usForces, iranForces } = situation
|
||||||
const [now, setNow] = useState(() => new Date())
|
const [now, setNow] = useState(() => new Date())
|
||||||
|
|
||||||
@@ -47,14 +52,21 @@ export function HeaderPanel() {
|
|||||||
<Clock className="h-4 w-4 shrink-0" />
|
<Clock className="h-4 w-4 shrink-0" />
|
||||||
<span className="min-w-[11rem] tabular-nums">{formatDateTime(now)}</span>
|
<span className="min-w-[11rem] tabular-nums">{formatDateTime(now)}</span>
|
||||||
</div>
|
</div>
|
||||||
{isConnected && (
|
{(isConnected || isReplayMode) && (
|
||||||
<span className="text-[10px] text-green-500/90">
|
<span className={`text-[10px] ${isReplayMode ? 'text-military-accent' : 'text-green-500/90'}`}>
|
||||||
{formatDataTime(situation.lastUpdated)} (实时更新)
|
{formatDataTime(situation.lastUpdated)} {isReplayMode ? '(回放)' : '(实时更新)'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to="/db"
|
||||||
|
className="flex items-center gap-1 rounded border border-military-border px-2 py-1 text-[10px] text-military-text-secondary hover:bg-military-border/30 hover:text-cyan-400"
|
||||||
|
>
|
||||||
|
<Database className="h-3 w-3" />
|
||||||
|
数据库
|
||||||
|
</Link>
|
||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
<>
|
<>
|
||||||
<Wifi className="h-3.5 w-3.5 text-green-500" />
|
<Wifi className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
|||||||
71
src/components/RecentUpdatesPanel.tsx
Normal file
71
src/components/RecentUpdatesPanel.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { SituationUpdate, ConflictEvent } from '@/data/mockData'
|
||||||
|
import { Newspaper, AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface RecentUpdatesPanelProps {
|
||||||
|
updates: SituationUpdate[]
|
||||||
|
conflictEvents?: ConflictEvent[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAT_LABELS: Record<string, string> = {
|
||||||
|
deployment: '部署',
|
||||||
|
alert: '警报',
|
||||||
|
intel: '情报',
|
||||||
|
diplomatic: '外交',
|
||||||
|
other: '其他',
|
||||||
|
}
|
||||||
|
const SEV_COLORS: Record<string, string> = {
|
||||||
|
low: 'text-military-text-secondary',
|
||||||
|
medium: 'text-amber-400',
|
||||||
|
high: 'text-orange-500',
|
||||||
|
critical: 'text-red-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - d.getTime()
|
||||||
|
if (diff < 60000) return '刚刚'
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||||||
|
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentUpdatesPanel({ updates, conflictEvents = [], className = '' }: RecentUpdatesPanelProps) {
|
||||||
|
// 优先展示 GDELT 冲突事件(最新 10 条),无则用 updates
|
||||||
|
const fromConflict = (conflictEvents || [])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((e) => ({ id: e.event_id, summary: e.title, timestamp: e.event_time, category: 'alert' as const, severity: (e.impact_score >= 7 ? 'high' : e.impact_score >= 4 ? 'medium' : 'low') as const }))
|
||||||
|
const list = fromConflict.length > 0 ? fromConflict : (updates || []).slice(0, 8)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex min-w-0 flex-1 flex-col overflow-hidden rounded border border-military-border bg-military-panel/80 ${className}`}>
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5 border-b border-military-border px-3 py-1.5">
|
||||||
|
<Newspaper className="h-3.5 w-3.5 shrink-0 text-military-accent" />
|
||||||
|
<span className="truncate text-[10px] font-semibold uppercase tracking-wider text-military-text-secondary">
|
||||||
|
近期动态
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-2 py-1">
|
||||||
|
{list.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-[11px] text-military-text-secondary">暂无动态</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{list.map((u) => (
|
||||||
|
<li key={u.id} className="flex gap-2 border-b border-military-border/50 pb-2 last:border-0 last:pb-0">
|
||||||
|
<span className={`shrink-0 text-[9px] ${SEV_COLORS[u.severity] ?? 'text-military-text-secondary'}`}>
|
||||||
|
{u.severity === 'critical' && <AlertTriangle className="inline h-2.5 w-2.5" />}
|
||||||
|
{CAT_LABELS[u.category] ?? u.category}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 text-right">
|
||||||
|
<p className="text-[11px] leading-tight text-military-text-primary line-clamp-2">{u.summary}</p>
|
||||||
|
<span className="mt-0.5 block text-[9px] text-military-text-secondary">{formatTime(u.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
src/components/TimelinePanel.tsx
Normal file
142
src/components/TimelinePanel.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { Play, Pause, SkipBack, SkipForward, History } from 'lucide-react'
|
||||||
|
import { usePlaybackStore, REPLAY_TICKS, REPLAY_START, REPLAY_END } from '@/store/playbackStore'
|
||||||
|
|
||||||
|
function formatTick(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimelinePanel() {
|
||||||
|
const {
|
||||||
|
isReplayMode,
|
||||||
|
playbackTime,
|
||||||
|
isPlaying,
|
||||||
|
speedSecPerTick,
|
||||||
|
setReplayMode,
|
||||||
|
setPlaybackTime,
|
||||||
|
setIsPlaying,
|
||||||
|
stepForward,
|
||||||
|
stepBack,
|
||||||
|
setSpeed,
|
||||||
|
} = usePlaybackStore()
|
||||||
|
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || !isReplayMode) {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
const current = usePlaybackStore.getState().playbackTime
|
||||||
|
const i = REPLAY_TICKS.indexOf(current)
|
||||||
|
if (i >= REPLAY_TICKS.length - 1) {
|
||||||
|
setIsPlaying(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPlaybackTime(REPLAY_TICKS[i + 1])
|
||||||
|
}, speedSecPerTick * 1000)
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current)
|
||||||
|
}
|
||||||
|
}, [isPlaying, isReplayMode, speedSecPerTick, setPlaybackTime, setIsPlaying])
|
||||||
|
|
||||||
|
const index = REPLAY_TICKS.indexOf(playbackTime)
|
||||||
|
const value = index >= 0 ? index : REPLAY_TICKS.length - 1
|
||||||
|
|
||||||
|
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const i = parseInt(e.target.value, 10)
|
||||||
|
setPlaybackTime(REPLAY_TICKS[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shrink-0 border-b border-military-border bg-military-panel/95 px-3 py-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReplayMode(!isReplayMode)}
|
||||||
|
className={`flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
|
isReplayMode
|
||||||
|
? 'bg-military-accent/30 text-military-accent'
|
||||||
|
: 'bg-military-border/50 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<History className="h-3.5 w-3.5" />
|
||||||
|
数据回放
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isReplayMode && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={stepBack}
|
||||||
|
disabled={index <= 0}
|
||||||
|
className="rounded p-1 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-military-text-secondary"
|
||||||
|
title="上一步"
|
||||||
|
>
|
||||||
|
<SkipBack className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsPlaying(!isPlaying)}
|
||||||
|
className="rounded p-1 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary"
|
||||||
|
title={isPlaying ? '暂停' : '播放'}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={stepForward}
|
||||||
|
disabled={index >= REPLAY_TICKS.length - 1}
|
||||||
|
className="rounded p-1 text-military-text-secondary hover:bg-military-border hover:text-military-text-primary disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-military-text-secondary"
|
||||||
|
title="下一步"
|
||||||
|
>
|
||||||
|
<SkipForward className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2 lg:min-w-[320px]">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={REPLAY_TICKS.length - 1}
|
||||||
|
value={value}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-military-border [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-military-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-[11px] tabular-nums text-military-text-secondary">
|
||||||
|
<span>{formatTick(REPLAY_START)}</span>
|
||||||
|
<span className="font-medium text-military-accent">{formatTick(playbackTime)}</span>
|
||||||
|
<span>{formatTick(REPLAY_END)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={speedSecPerTick}
|
||||||
|
onChange={(e) => setSpeed(Number(e.target.value))}
|
||||||
|
className="rounded border border-military-border bg-military-dark/80 px-2 py-1 text-[11px] text-military-text-secondary focus:border-military-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value={0.5}>0.5 秒/刻度</option>
|
||||||
|
<option value={1}>1 秒/刻度</option>
|
||||||
|
<option value={2}>2 秒/刻度</option>
|
||||||
|
<option value={3}>3 秒/刻度</option>
|
||||||
|
<option value={5}>5 秒/刻度</option>
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import Map, { Source, Layer } from 'react-map-gl'
|
|||||||
import type { MapRef } from 'react-map-gl'
|
import type { MapRef } from 'react-map-gl'
|
||||||
import type { Map as MapboxMap } from 'mapbox-gl'
|
import type { Map as MapboxMap } from 'mapbox-gl'
|
||||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||||
import { useSituationStore } from '@/store/situationStore'
|
import { useReplaySituation } from '@/hooks/useReplaySituation'
|
||||||
import {
|
import {
|
||||||
ATTACKED_TARGETS,
|
ATTACKED_TARGETS,
|
||||||
ALLIED_STRIKE_LOCATIONS,
|
ALLIED_STRIKE_LOCATIONS,
|
||||||
@@ -129,8 +129,8 @@ export function WarMap() {
|
|||||||
const lincolnPathsRef = useRef<[number, number][][]>([])
|
const lincolnPathsRef = useRef<[number, number][][]>([])
|
||||||
const fordPathsRef = useRef<[number, number][][]>([])
|
const fordPathsRef = useRef<[number, number][][]>([])
|
||||||
const israelPathsRef = useRef<[number, number][][]>([])
|
const israelPathsRef = useRef<[number, number][][]>([])
|
||||||
const { situation } = useSituationStore()
|
const situation = useReplaySituation()
|
||||||
const { usForces, iranForces } = situation
|
const { usForces, iranForces, conflictEvents = [] } = situation
|
||||||
|
|
||||||
const usLocs = (usForces.keyLocations || []) as KeyLoc[]
|
const usLocs = (usForces.keyLocations || []) as KeyLoc[]
|
||||||
const irLocs = (iranForces.keyLocations || []) as KeyLoc[]
|
const irLocs = (iranForces.keyLocations || []) as KeyLoc[]
|
||||||
@@ -239,6 +239,29 @@ export function WarMap() {
|
|||||||
[attackPaths]
|
[attackPaths]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GDELT 冲突事件:1–3 绿, 4–6 橙闪, 7–10 红脉
|
||||||
|
const { conflictEventsGreen, conflictEventsOrange, conflictEventsRed } = useMemo(() => {
|
||||||
|
const green: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||||
|
const orange: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||||
|
const red: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||||
|
for (const e of conflictEvents) {
|
||||||
|
const score = e.impact_score ?? 1
|
||||||
|
const f: GeoJSON.Feature<GeoJSON.Point> = {
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { event_id: e.event_id, impact_score: score },
|
||||||
|
geometry: { type: 'Point', coordinates: [e.lng, e.lat] },
|
||||||
|
}
|
||||||
|
if (score <= 3) green.push(f)
|
||||||
|
else if (score <= 6) orange.push(f)
|
||||||
|
else red.push(f)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
conflictEventsGreen: { type: 'FeatureCollection' as const, features: green },
|
||||||
|
conflictEventsOrange: { type: 'FeatureCollection' as const, features: orange },
|
||||||
|
conflictEventsRed: { type: 'FeatureCollection' as const, features: red },
|
||||||
|
}
|
||||||
|
}, [conflictEvents])
|
||||||
|
|
||||||
const hideNonBelligerentLabels = (map: MapboxMap) => {
|
const hideNonBelligerentLabels = (map: MapboxMap) => {
|
||||||
const labelLayers = [
|
const labelLayers = [
|
||||||
'country-label',
|
'country-label',
|
||||||
@@ -362,6 +385,20 @@ export function WarMap() {
|
|||||||
map.setPaintProperty('allied-strike-targets-pulse', 'circle-radius', r)
|
map.setPaintProperty('allied-strike-targets-pulse', 'circle-radius', r)
|
||||||
map.setPaintProperty('allied-strike-targets-pulse', 'circle-opacity', opacity)
|
map.setPaintProperty('allied-strike-targets-pulse', 'circle-opacity', opacity)
|
||||||
}
|
}
|
||||||
|
// GDELT 橙色 (4–6):闪烁
|
||||||
|
if (map.getLayer('gdelt-events-orange')) {
|
||||||
|
const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.004)
|
||||||
|
map.setPaintProperty('gdelt-events-orange', 'circle-opacity', blink)
|
||||||
|
}
|
||||||
|
// GDELT 红色 (7–10):脉冲扩散
|
||||||
|
if (map.getLayer('gdelt-events-red-pulse')) {
|
||||||
|
const cycle = 2200
|
||||||
|
const phase = (elapsed % cycle) / cycle
|
||||||
|
const r = 30 * phase
|
||||||
|
const opacity = Math.max(0, 1 - phase * 1.1)
|
||||||
|
map.setPaintProperty('gdelt-events-red-pulse', 'circle-radius', r)
|
||||||
|
map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity)
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
animRef.current = requestAnimationFrame(tick)
|
animRef.current = requestAnimationFrame(tick)
|
||||||
}
|
}
|
||||||
@@ -376,7 +413,10 @@ export function WarMap() {
|
|||||||
(map.getSource('attack-dots') && attackPathsRef.current.length > 0) ||
|
(map.getSource('attack-dots') && attackPathsRef.current.length > 0) ||
|
||||||
(map.getSource('allied-strike-dots-lincoln') && lincolnPathsRef.current.length > 0) ||
|
(map.getSource('allied-strike-dots-lincoln') && lincolnPathsRef.current.length > 0) ||
|
||||||
(map.getSource('allied-strike-dots-ford') && fordPathsRef.current.length > 0) ||
|
(map.getSource('allied-strike-dots-ford') && fordPathsRef.current.length > 0) ||
|
||||||
(map.getSource('allied-strike-dots-israel') && israelPathsRef.current.length > 0)
|
(map.getSource('allied-strike-dots-israel') && israelPathsRef.current.length > 0) ||
|
||||||
|
map.getSource('gdelt-events-green') ||
|
||||||
|
map.getSource('gdelt-events-orange') ||
|
||||||
|
map.getSource('gdelt-events-red')
|
||||||
if (hasAnim) {
|
if (hasAnim) {
|
||||||
animRef.current = requestAnimationFrame(tick)
|
animRef.current = requestAnimationFrame(tick)
|
||||||
} else {
|
} else {
|
||||||
@@ -439,6 +479,15 @@ export function WarMap() {
|
|||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-[#22D3EE]" /> 以色列打击
|
<span className="h-1.5 w-1.5 rounded-full bg-[#22D3EE]" /> 以色列打击
|
||||||
</span>
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-[#22C55E]" /> 低烈度
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-[#F97316]" /> 中烈度
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-[#EF4444]" /> 高烈度
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Map
|
<Map
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
@@ -537,6 +586,52 @@ export function WarMap() {
|
|||||||
/>
|
/>
|
||||||
</Source>
|
</Source>
|
||||||
|
|
||||||
|
{/* GDELT 冲突事件:1–3 绿点, 4–6 橙闪, 7–10 红脉 */}
|
||||||
|
<Source id="gdelt-events-green" type="geojson" data={conflictEventsGreen}>
|
||||||
|
<Layer
|
||||||
|
id="gdelt-events-green"
|
||||||
|
type="circle"
|
||||||
|
paint={{
|
||||||
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 2, 8, 4, 12, 6],
|
||||||
|
'circle-color': '#22C55E',
|
||||||
|
'circle-stroke-width': 0.5,
|
||||||
|
'circle-stroke-color': '#fff',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Source>
|
||||||
|
<Source id="gdelt-events-orange" type="geojson" data={conflictEventsOrange}>
|
||||||
|
<Layer
|
||||||
|
id="gdelt-events-orange"
|
||||||
|
type="circle"
|
||||||
|
paint={{
|
||||||
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 8, 5, 12, 8],
|
||||||
|
'circle-color': '#F97316',
|
||||||
|
'circle-opacity': 0.8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Source>
|
||||||
|
<Source id="gdelt-events-red" type="geojson" data={conflictEventsRed}>
|
||||||
|
<Layer
|
||||||
|
id="gdelt-events-red-dot"
|
||||||
|
type="circle"
|
||||||
|
paint={{
|
||||||
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 2, 8, 4, 12, 6],
|
||||||
|
'circle-color': '#EF4444',
|
||||||
|
'circle-stroke-width': 0.5,
|
||||||
|
'circle-stroke-color': '#fff',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Layer
|
||||||
|
id="gdelt-events-red-pulse"
|
||||||
|
type="circle"
|
||||||
|
paint={{
|
||||||
|
'circle-radius': 0,
|
||||||
|
'circle-color': 'rgba(239, 68, 68, 0.5)',
|
||||||
|
'circle-opacity': 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Source>
|
||||||
|
|
||||||
{/* 美以联军打击伊朗:路径线 */}
|
{/* 美以联军打击伊朗:路径线 */}
|
||||||
<Source id="allied-strike-lines-lincoln" type="geojson" data={lincolnLinesGeoJson}>
|
<Source id="allied-strike-lines-lincoln" type="geojson" data={lincolnLinesGeoJson}>
|
||||||
<Layer
|
<Layer
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export interface PowerIndex {
|
|||||||
export interface CombatLosses {
|
export interface CombatLosses {
|
||||||
bases: { destroyed: number; damaged: number }
|
bases: { destroyed: number; damaged: number }
|
||||||
personnelCasualties: { killed: number; wounded: number }
|
personnelCasualties: { killed: number; wounded: number }
|
||||||
|
/** 平民伤亡 */
|
||||||
|
civilianCasualties?: { killed: number; wounded: number }
|
||||||
aircraft: number
|
aircraft: number
|
||||||
warships: number
|
warships: number
|
||||||
armor: number
|
armor: number
|
||||||
@@ -45,6 +47,23 @@ export interface SituationUpdate {
|
|||||||
severity: 'low' | 'medium' | 'high' | 'critical'
|
severity: 'low' | 'medium' | 'high' | 'critical'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConflictEvent {
|
||||||
|
event_id: string
|
||||||
|
event_time: string
|
||||||
|
title: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
impact_score: number
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConflictStats {
|
||||||
|
total_events: number
|
||||||
|
high_impact_events: number
|
||||||
|
estimated_casualties: number
|
||||||
|
estimated_strike_count: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface MilitarySituation {
|
export interface MilitarySituation {
|
||||||
lastUpdated: string
|
lastUpdated: string
|
||||||
usForces: {
|
usForces: {
|
||||||
@@ -86,6 +105,12 @@ export interface MilitarySituation {
|
|||||||
retaliationSentimentHistory: { time: string; value: number }[]
|
retaliationSentimentHistory: { time: string; value: number }[]
|
||||||
}
|
}
|
||||||
recentUpdates: SituationUpdate[]
|
recentUpdates: SituationUpdate[]
|
||||||
|
/** GDELT 冲突事件 (1–3 绿点, 4–6 橙闪, 7–10 红脉) */
|
||||||
|
conflictEvents?: ConflictEvent[]
|
||||||
|
/** 战损统计(展示用) */
|
||||||
|
conflictStats?: ConflictStats
|
||||||
|
/** 平民伤亡合计(不区分阵营) */
|
||||||
|
civilianCasualtiesTotal?: { killed: number; wounded: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INITIAL_MOCK_DATA: MilitarySituation = {
|
export const INITIAL_MOCK_DATA: MilitarySituation = {
|
||||||
@@ -122,6 +147,7 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
|
|||||||
combatLosses: {
|
combatLosses: {
|
||||||
bases: { destroyed: 0, damaged: 2 },
|
bases: { destroyed: 0, damaged: 2 },
|
||||||
personnelCasualties: { killed: 127, wounded: 384 },
|
personnelCasualties: { killed: 127, wounded: 384 },
|
||||||
|
civilianCasualties: { killed: 18, wounded: 52 },
|
||||||
aircraft: 2,
|
aircraft: 2,
|
||||||
warships: 0,
|
warships: 0,
|
||||||
armor: 0,
|
armor: 0,
|
||||||
@@ -171,6 +197,7 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
|
|||||||
combatLosses: {
|
combatLosses: {
|
||||||
bases: { destroyed: 3, damaged: 8 },
|
bases: { destroyed: 3, damaged: 8 },
|
||||||
personnelCasualties: { killed: 2847, wounded: 5620 },
|
personnelCasualties: { killed: 2847, wounded: 5620 },
|
||||||
|
civilianCasualties: { killed: 412, wounded: 1203 },
|
||||||
aircraft: 24,
|
aircraft: 24,
|
||||||
warships: 12,
|
warships: 12,
|
||||||
armor: 18,
|
armor: 18,
|
||||||
@@ -219,4 +246,7 @@ export const INITIAL_MOCK_DATA: MilitarySituation = {
|
|||||||
severity: 'low',
|
severity: 'low',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
conflictEvents: [],
|
||||||
|
conflictStats: { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
|
||||||
|
civilianCasualtiesTotal: { killed: 430, wounded: 1255 },
|
||||||
}
|
}
|
||||||
|
|||||||
146
src/hooks/useReplaySituation.ts
Normal file
146
src/hooks/useReplaySituation.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import type { MilitarySituation } from '@/data/mockData'
|
||||||
|
import { useSituationStore } from '@/store/situationStore'
|
||||||
|
import { usePlaybackStore } from '@/store/playbackStore'
|
||||||
|
|
||||||
|
/** 将系列时间映射到回放日 (2026-03-01) 以便按当天时刻插值 */
|
||||||
|
function toReplayDay(iso: string, baseDay: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
const [y, m, day] = baseDay.slice(0, 10).split('-').map(Number)
|
||||||
|
return new Date(y, (m || 1) - 1, day || 1, d.getUTCHours(), d.getUTCMinutes(), 0, 0).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateAt(
|
||||||
|
series: { time: string; value: number }[],
|
||||||
|
at: string,
|
||||||
|
baseDay = '2026-03-01'
|
||||||
|
): number {
|
||||||
|
if (series.length === 0) return 0
|
||||||
|
const t = new Date(at).getTime()
|
||||||
|
const mapped = series.map((p) => ({
|
||||||
|
time: toReplayDay(p.time, baseDay),
|
||||||
|
value: p.value,
|
||||||
|
}))
|
||||||
|
const sorted = [...mapped].sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime())
|
||||||
|
const before = sorted.filter((p) => new Date(p.time).getTime() <= t)
|
||||||
|
const after = sorted.filter((p) => new Date(p.time).getTime() > t)
|
||||||
|
if (before.length === 0) return sorted[0].value
|
||||||
|
if (after.length === 0) return sorted[sorted.length - 1].value
|
||||||
|
const a = before[before.length - 1]
|
||||||
|
const b = after[0]
|
||||||
|
const ta = new Date(a.time).getTime()
|
||||||
|
const tb = new Date(b.time).getTime()
|
||||||
|
const f = tb === ta ? 1 : (t - ta) / (tb - ta)
|
||||||
|
return a.value + f * (b.value - a.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function linearProgress(start: string, end: string, at: string): number {
|
||||||
|
const ts = new Date(start).getTime()
|
||||||
|
const te = new Date(end).getTime()
|
||||||
|
const ta = new Date(at).getTime()
|
||||||
|
if (ta <= ts) return 0
|
||||||
|
if (ta >= te) return 1
|
||||||
|
return (ta - ts) / (te - ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据回放时刻派生态势数据 */
|
||||||
|
export function useReplaySituation(): MilitarySituation {
|
||||||
|
const situation = useSituationStore((s) => s.situation)
|
||||||
|
const { isReplayMode, playbackTime } = usePlaybackStore()
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!isReplayMode) return situation
|
||||||
|
|
||||||
|
const progress = linearProgress('2026-03-01T02:00:00.000Z', '2026-03-01T11:45:00.000Z', playbackTime)
|
||||||
|
|
||||||
|
// 华尔街趋势、反击情绪:按时间插值
|
||||||
|
const wsValue = interpolateAt(situation.usForces.wallStreetInvestmentTrend, playbackTime)
|
||||||
|
const retValue = interpolateAt(situation.iranForces.retaliationSentimentHistory, playbackTime)
|
||||||
|
|
||||||
|
// 战斗损失:从 0 线性增长到当前值
|
||||||
|
const lerp = (a: number, b: number) => Math.round(a + progress * (b - a))
|
||||||
|
const usLoss = situation.usForces.combatLosses
|
||||||
|
const irLoss = situation.iranForces.combatLosses
|
||||||
|
const civTotal = situation.civilianCasualtiesTotal ?? { killed: 0, wounded: 0 }
|
||||||
|
const usLossesAt = {
|
||||||
|
bases: {
|
||||||
|
destroyed: lerp(0, usLoss.bases.destroyed),
|
||||||
|
damaged: lerp(0, usLoss.bases.damaged),
|
||||||
|
},
|
||||||
|
personnelCasualties: {
|
||||||
|
killed: lerp(0, usLoss.personnelCasualties.killed),
|
||||||
|
wounded: lerp(0, usLoss.personnelCasualties.wounded),
|
||||||
|
},
|
||||||
|
civilianCasualties: { killed: 0, wounded: 0 },
|
||||||
|
aircraft: lerp(0, usLoss.aircraft),
|
||||||
|
warships: lerp(0, usLoss.warships),
|
||||||
|
armor: lerp(0, usLoss.armor),
|
||||||
|
vehicles: lerp(0, usLoss.vehicles),
|
||||||
|
}
|
||||||
|
const irLossesAt = {
|
||||||
|
bases: {
|
||||||
|
destroyed: lerp(0, irLoss.bases.destroyed),
|
||||||
|
damaged: lerp(0, irLoss.bases.damaged),
|
||||||
|
},
|
||||||
|
personnelCasualties: {
|
||||||
|
killed: lerp(0, irLoss.personnelCasualties.killed),
|
||||||
|
wounded: lerp(0, irLoss.personnelCasualties.wounded),
|
||||||
|
},
|
||||||
|
civilianCasualties: { killed: 0, wounded: 0 },
|
||||||
|
aircraft: lerp(0, irLoss.aircraft),
|
||||||
|
warships: lerp(0, irLoss.warships),
|
||||||
|
armor: lerp(0, irLoss.armor),
|
||||||
|
vehicles: lerp(0, irLoss.vehicles),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 被袭基地:按 damage_level 排序,高损毁先出现;根据 progress 决定显示哪些为 attacked
|
||||||
|
const usLocs = situation.usForces.keyLocations || []
|
||||||
|
const attackedBases = usLocs
|
||||||
|
.filter((loc) => loc.status === 'attacked')
|
||||||
|
.sort((a, b) => (b.damage_level ?? 0) - (a.damage_level ?? 0))
|
||||||
|
const totalAttacked = attackedBases.length
|
||||||
|
const shownAttackedCount = Math.round(progress * totalAttacked)
|
||||||
|
const attackedNames = new Set(
|
||||||
|
attackedBases.slice(0, shownAttackedCount).map((l) => l.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
const usLocsAt = usLocs.map((loc) => {
|
||||||
|
if (loc.status === 'attacked' && !attackedNames.has(loc.name)) {
|
||||||
|
return { ...loc, status: 'operational' as const }
|
||||||
|
}
|
||||||
|
return { ...loc }
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...situation,
|
||||||
|
lastUpdated: playbackTime,
|
||||||
|
civilianCasualtiesTotal: {
|
||||||
|
killed: lerp(0, civTotal.killed),
|
||||||
|
wounded: lerp(0, civTotal.wounded),
|
||||||
|
},
|
||||||
|
usForces: {
|
||||||
|
...situation.usForces,
|
||||||
|
keyLocations: usLocsAt,
|
||||||
|
combatLosses: usLossesAt,
|
||||||
|
wallStreetInvestmentTrend: [
|
||||||
|
...situation.usForces.wallStreetInvestmentTrend.filter((p) => new Date(p.time).getTime() <= new Date(playbackTime).getTime()),
|
||||||
|
{ time: playbackTime, value: wsValue },
|
||||||
|
].slice(-20),
|
||||||
|
},
|
||||||
|
iranForces: {
|
||||||
|
...situation.iranForces,
|
||||||
|
combatLosses: irLossesAt,
|
||||||
|
retaliationSentiment: retValue,
|
||||||
|
retaliationSentimentHistory: [
|
||||||
|
...situation.iranForces.retaliationSentimentHistory.filter((p) => new Date(p.time).getTime() <= new Date(playbackTime).getTime()),
|
||||||
|
{ time: playbackTime, value: retValue },
|
||||||
|
].slice(-20),
|
||||||
|
},
|
||||||
|
recentUpdates: (situation.recentUpdates || []).filter(
|
||||||
|
(u) => new Date(u.timestamp).getTime() <= new Date(playbackTime).getTime()
|
||||||
|
),
|
||||||
|
conflictEvents: situation.conflictEvents || [],
|
||||||
|
conflictStats: situation.conflictStats || { total_events: 0, high_impact_events: 0, estimated_casualties: 0, estimated_strike_count: 0 },
|
||||||
|
}
|
||||||
|
}, [situation, isReplayMode, playbackTime])
|
||||||
|
}
|
||||||
@@ -31,6 +31,11 @@ body,
|
|||||||
font-family: 'Orbitron', sans-serif;
|
font-family: 'Orbitron', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 数据库面板:易读字体 */
|
||||||
|
.font-db {
|
||||||
|
font-family: 'Noto Sans SC', system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tabular numbers for aligned stat display */
|
/* Tabular numbers for aligned stat display */
|
||||||
.tabular-nums {
|
.tabular-nums {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { HeaderPanel } from '@/components/HeaderPanel'
|
import { HeaderPanel } from '@/components/HeaderPanel'
|
||||||
|
import { TimelinePanel } from '@/components/TimelinePanel'
|
||||||
import { ForcePanel } from '@/components/ForcePanel'
|
import { ForcePanel } from '@/components/ForcePanel'
|
||||||
import { WarMap } from '@/components/WarMap'
|
import { WarMap } from '@/components/WarMap'
|
||||||
import { CombatLossesPanel } from '@/components/CombatLossesPanel'
|
import { CombatLossesPanel } from '@/components/CombatLossesPanel'
|
||||||
|
import { EventTimelinePanel } from '@/components/EventTimelinePanel'
|
||||||
import { BaseStatusPanel } from '@/components/BaseStatusPanel'
|
import { BaseStatusPanel } from '@/components/BaseStatusPanel'
|
||||||
import { PowerChart } from '@/components/PowerChart'
|
import { PowerChart } from '@/components/PowerChart'
|
||||||
import { InvestmentTrendChart } from '@/components/InvestmentTrendChart'
|
import { InvestmentTrendChart } from '@/components/InvestmentTrendChart'
|
||||||
import { RetaliationGauge } from '@/components/RetaliationGauge'
|
import { RetaliationGauge } from '@/components/RetaliationGauge'
|
||||||
import { useSituationStore } from '@/store/situationStore'
|
import { useSituationStore } from '@/store/situationStore'
|
||||||
|
import { useReplaySituation } from '@/hooks/useReplaySituation'
|
||||||
import { fetchAndSetSituation, startSituationWebSocket, stopSituationWebSocket } from '@/store/situationStore'
|
import { fetchAndSetSituation, startSituationWebSocket, stopSituationWebSocket } from '@/store/situationStore'
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const situation = useSituationStore((s) => s.situation)
|
const situation = useReplaySituation()
|
||||||
const isLoading = useSituationStore((s) => s.isLoading)
|
const isLoading = useSituationStore((s) => s.isLoading)
|
||||||
const lastError = useSituationStore((s) => s.lastError)
|
const lastError = useSituationStore((s) => s.lastError)
|
||||||
|
|
||||||
@@ -28,6 +31,7 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<HeaderPanel />
|
<HeaderPanel />
|
||||||
|
<TimelinePanel />
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-auto lg:flex-row lg:overflow-hidden">
|
<div className="flex min-h-0 flex-1 flex-col overflow-auto lg:flex-row lg:overflow-hidden">
|
||||||
<aside className="flex min-h-0 min-w-0 shrink-0 flex-col gap-2 overflow-y-auto overflow-x-visible border-b border-military-border p-3 lg:min-w-[320px] lg:max-w-[340px] lg:border-b-0 lg:border-r lg:p-4">
|
<aside className="flex min-h-0 min-w-0 shrink-0 flex-col gap-2 overflow-y-auto overflow-x-visible border-b border-military-border p-3 lg:min-w-[320px] lg:max-w-[340px] lg:border-b-0 lg:border-r lg:p-4">
|
||||||
@@ -63,8 +67,11 @@ export function Dashboard() {
|
|||||||
<CombatLossesPanel
|
<CombatLossesPanel
|
||||||
usLosses={situation.usForces.combatLosses}
|
usLosses={situation.usForces.combatLosses}
|
||||||
iranLosses={situation.iranForces.combatLosses}
|
iranLosses={situation.iranForces.combatLosses}
|
||||||
className="min-w-0 flex-1 border-t-0"
|
conflictStats={situation.conflictStats}
|
||||||
|
civilianTotal={situation.civilianCasualtiesTotal}
|
||||||
|
className="min-w-0 flex-1 py-1"
|
||||||
/>
|
/>
|
||||||
|
<EventTimelinePanel updates={situation.recentUpdates} conflictEvents={situation.conflictEvents} className="min-w-0 shrink-0 min-h-[80px] overflow-hidden lg:min-w-[240px]" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
161
src/pages/DbDashboard.tsx
Normal file
161
src/pages/DbDashboard.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Database, Table, ArrowLeft, RefreshCw } from 'lucide-react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface TableData {
|
||||||
|
[table: string]: Record<string, unknown>[] | { error: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DbDashboard() {
|
||||||
|
const [data, setData] = useState<TableData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set(['situation_update', 'combat_losses', 'conflict_stats']))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
const t = setInterval(fetchData, 30000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/db/dashboard')
|
||||||
|
if (!res.ok) throw new Error(res.statusText)
|
||||||
|
const json = await res.json()
|
||||||
|
setData(json)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = (name: string) => {
|
||||||
|
setExpanded((s) => {
|
||||||
|
const next = new Set(s)
|
||||||
|
if (next.has(name)) next.delete(name)
|
||||||
|
else next.add(name)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-military-dark text-military-text-secondary">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-military-dark font-db text-military-text-primary">
|
||||||
|
<header className="sticky top-0 z-10 flex items-center justify-between border-b border-military-border bg-military-panel/95 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-2 rounded border border-military-border px-3 py-1.5 text-sm text-military-text-secondary hover:bg-military-border/30 hover:text-military-text-primary"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
返回主面板
|
||||||
|
</Link>
|
||||||
|
<span className="flex items-center gap-2 text-lg">
|
||||||
|
<Database className="h-5 w-5 text-cyan-400" />
|
||||||
|
数据库内容
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchData}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 rounded border border-military-border px-3 py-1.5 text-sm text-military-text-secondary hover:bg-military-border/30 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mx-4 mt-4 rounded border border-amber-600/50 bg-amber-950/30 px-4 py-2 text-amber-400">
|
||||||
|
{error}(请确保 API 已启动:npm run api)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className="max-w-6xl space-y-4 p-4">
|
||||||
|
{data &&
|
||||||
|
Object.entries(data).map(([name, rows]) => {
|
||||||
|
const isExpanded = expanded.has(name)
|
||||||
|
const isError = rows && typeof rows === 'object' && 'error' in rows
|
||||||
|
const arr = Array.isArray(rows) ? rows : []
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={name}
|
||||||
|
className="rounded border border-military-border bg-military-panel/80 overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(name)}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-military-border/20"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 font-medium">
|
||||||
|
<Table className="h-4 w-4 text-cyan-400" />
|
||||||
|
{name}
|
||||||
|
<span className="text-military-text-secondary font-normal">
|
||||||
|
{isError ? '(错误)' : `(${arr.length} 条)`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-military-text-secondary">{isExpanded ? '▼' : '▶'}</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-military-border overflow-x-auto">
|
||||||
|
{isError ? (
|
||||||
|
<pre className="p-4 text-sm text-amber-400">{(rows as { error: string }).error}</pre>
|
||||||
|
) : arr.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-military-text-secondary">无数据</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-military-border bg-military-dark/50">
|
||||||
|
{Object.keys(arr[0] as object).map((col) => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
className="whitespace-nowrap px-3 py-2 font-medium text-cyan-400"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{arr.map((row, i) => (
|
||||||
|
<tr
|
||||||
|
key={i}
|
||||||
|
className="border-b border-military-border/50 hover:bg-military-border/10"
|
||||||
|
>
|
||||||
|
{Object.values(row as object).map((v, j) => (
|
||||||
|
<td
|
||||||
|
key={j}
|
||||||
|
className="max-w-xs truncate px-3 py-2 text-military-text-secondary"
|
||||||
|
title={String(v ?? '')}
|
||||||
|
>
|
||||||
|
{v === null || v === undefined
|
||||||
|
? '—'
|
||||||
|
: typeof v === 'object'
|
||||||
|
? JSON.stringify(v)
|
||||||
|
: String(v)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
src/store/playbackStore.ts
Normal file
80
src/store/playbackStore.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
const REPLAY_DAY = '2026-03-01'
|
||||||
|
const TICK_MS = 30 * 60 * 1000 // 30 minutes
|
||||||
|
|
||||||
|
export const REPLAY_START = `${REPLAY_DAY}T00:00:00.000Z`
|
||||||
|
export const REPLAY_END = `${REPLAY_DAY}T23:30:00.000Z`
|
||||||
|
|
||||||
|
function parseTime(iso: string): number {
|
||||||
|
return new Date(iso).getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTicks(): string[] {
|
||||||
|
const ticks: string[] = []
|
||||||
|
let t = parseTime(REPLAY_START)
|
||||||
|
const end = parseTime(REPLAY_END)
|
||||||
|
while (t <= end) {
|
||||||
|
ticks.push(new Date(t).toISOString())
|
||||||
|
t += TICK_MS
|
||||||
|
}
|
||||||
|
return ticks
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REPLAY_TICKS = getTicks()
|
||||||
|
|
||||||
|
export interface PlaybackState {
|
||||||
|
/** 是否开启回放模式 */
|
||||||
|
isReplayMode: boolean
|
||||||
|
/** 当前回放时刻 (ISO) */
|
||||||
|
playbackTime: string
|
||||||
|
/** 是否正在自动播放 */
|
||||||
|
isPlaying: boolean
|
||||||
|
/** 播放速度 (秒/刻度) */
|
||||||
|
speedSecPerTick: number
|
||||||
|
setReplayMode: (v: boolean) => void
|
||||||
|
setPlaybackTime: (iso: string) => void
|
||||||
|
setIsPlaying: (v: boolean) => void
|
||||||
|
stepForward: () => void
|
||||||
|
stepBack: () => void
|
||||||
|
setSpeed: (sec: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||||
|
isReplayMode: false,
|
||||||
|
playbackTime: REPLAY_END,
|
||||||
|
isPlaying: false,
|
||||||
|
speedSecPerTick: 2,
|
||||||
|
|
||||||
|
setReplayMode: (v) => set({ isReplayMode: v, isPlaying: false }),
|
||||||
|
|
||||||
|
setPlaybackTime: (iso) => {
|
||||||
|
const ticks = REPLAY_TICKS
|
||||||
|
if (ticks.includes(iso)) {
|
||||||
|
set({ playbackTime: iso })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const idx = ticks.findIndex((t) => t >= iso)
|
||||||
|
const clamp = Math.max(0, Math.min(idx < 0 ? ticks.length - 1 : idx, ticks.length - 1))
|
||||||
|
set({ playbackTime: ticks[clamp] })
|
||||||
|
},
|
||||||
|
|
||||||
|
setIsPlaying: (v) => set({ isPlaying: v }),
|
||||||
|
|
||||||
|
stepForward: () => {
|
||||||
|
const { playbackTime } = get()
|
||||||
|
const ticks = REPLAY_TICKS
|
||||||
|
const i = ticks.indexOf(playbackTime)
|
||||||
|
if (i < ticks.length - 1) set({ playbackTime: ticks[i + 1] })
|
||||||
|
else set({ isPlaying: false })
|
||||||
|
},
|
||||||
|
|
||||||
|
stepBack: () => {
|
||||||
|
const { playbackTime } = get()
|
||||||
|
const ticks = REPLAY_TICKS
|
||||||
|
const i = ticks.indexOf(playbackTime)
|
||||||
|
if (i > 0) set({ playbackTime: ticks[i - 1] })
|
||||||
|
},
|
||||||
|
|
||||||
|
setSpeed: (sec) => set({ speedSecPerTick: sec }),
|
||||||
|
}))
|
||||||
@@ -47,20 +47,36 @@ export function fetchAndSetSituation(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let disconnectWs: (() => void) | null = null
|
let disconnectWs: (() => void) | null = null
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 5000
|
||||||
|
|
||||||
|
function pollSituation() {
|
||||||
|
fetchSituation()
|
||||||
|
.then((situation) => useSituationStore.getState().setSituation(situation))
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
export function startSituationWebSocket(): () => void {
|
export function startSituationWebSocket(): () => void {
|
||||||
useSituationStore.getState().setConnected(true)
|
|
||||||
useSituationStore.getState().setLastError(null)
|
useSituationStore.getState().setLastError(null)
|
||||||
|
|
||||||
disconnectWs = connectSituationWebSocket((data) => {
|
disconnectWs = connectSituationWebSocket((data) => {
|
||||||
|
useSituationStore.getState().setConnected(true)
|
||||||
useSituationStore.getState().setSituation(data as MilitarySituation)
|
useSituationStore.getState().setSituation(data as MilitarySituation)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
pollSituation()
|
||||||
|
pollInterval = setInterval(pollSituation, POLL_INTERVAL_MS)
|
||||||
|
|
||||||
return stopSituationWebSocket
|
return stopSituationWebSocket
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopSituationWebSocket(): void {
|
export function stopSituationWebSocket(): void {
|
||||||
disconnectWs?.()
|
disconnectWs?.()
|
||||||
disconnectWs = null
|
disconnectWs = null
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
pollInterval = null
|
||||||
|
}
|
||||||
useSituationStore.getState().setConnected(false)
|
useSituationStore.getState().setConnected(false)
|
||||||
}
|
}
|
||||||
|
|||||||
48
start.sh
Executable file
48
start.sh
Executable file
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 一键启动 US-Iran 态势面板:API + 前端 + 爬虫服务
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# 无 Ollama 时禁用 AI;GDELT 国内常超时,仅用 RSS 更新
|
||||||
|
export CLEANER_AI_DISABLED=1
|
||||||
|
export PARSER_AI_DISABLED=1
|
||||||
|
export GDELT_DISABLED=1
|
||||||
|
export RSS_INTERVAL_SEC=60
|
||||||
|
|
||||||
|
echo "==> Checking dependencies..."
|
||||||
|
[ ! -d node_modules ] && npm install
|
||||||
|
|
||||||
|
echo "==> Checking crawler Python deps..."
|
||||||
|
pip install -q -r crawler/requirements.txt 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "==> Seeding database (if needed)..."
|
||||||
|
[ ! -f server/data.db ] && npm run api:seed
|
||||||
|
|
||||||
|
echo "==> Starting API (http://localhost:3001)..."
|
||||||
|
npm run api &
|
||||||
|
API_PID=$!
|
||||||
|
|
||||||
|
# 等待 API 就绪后再启动爬虫
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
echo "==> Starting GDELT/RSS crawler (http://localhost:8000)..."
|
||||||
|
npm run gdelt &
|
||||||
|
GDELT_PID=$!
|
||||||
|
|
||||||
|
echo "==> Starting frontend (Vite dev server)..."
|
||||||
|
npm run dev &
|
||||||
|
DEV_PID=$!
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo ""
|
||||||
|
echo "==> Shutting down..."
|
||||||
|
kill $API_PID $GDELT_PID $DEV_PID 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
trap cleanup SIGINT SIGTERM
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "==> All services running. Frontend: http://localhost:5173 | API: http://localhost:3001"
|
||||||
|
echo " Press Ctrl+C to stop all."
|
||||||
|
echo ""
|
||||||
|
wait
|
||||||
Reference in New Issue
Block a user