fix:优化数据来源
This commit is contained in:
95
crawler/README.md
Normal file
95
crawler/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# 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、BBC World/MiddleEast、Al Jazeera、NYT World |
|
||||
| 过滤 | 标题/摘要需含 `KEYWORDS` 之一(iran、usa、strike、military 等) |
|
||||
| 更新 | 爬虫 45 秒拉一次(`RSS_INTERVAL_SEC`),优先保证事件脉络 |
|
||||
| 优先级 | 启动时先拉 RSS,再拉 GDELT |
|
||||
|
||||
**GDELT 无法访问时**:设置 `GDELT_DISABLED=1`,仅用 RSS 新闻即可维持事件脉络。
|
||||
|
||||
---
|
||||
|
||||
**事件脉络可实时更新**:爬虫抓取后 → 写入 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(优先保证事件脉络)
|
||||
|
||||
## 冲突强度 (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__/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__/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.
33
crawler/config.py
Normal file
33
crawler/config.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# -*- 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://feeds.bbci.co.uk/news/world/rss.xml",
|
||||
"https://feeds.bbci.co.uk/news/world/middle_east/rss.xml",
|
||||
"https://www.aljazeera.com/xml/rss/all.xml",
|
||||
"https://www.aljazeera.com/xml/rss/middleeast.xml",
|
||||
"https://rss.nytimes.com/services/xml/rss/nyt/World.xml",
|
||||
]
|
||||
|
||||
# 关键词过滤:至少匹配一个才会入库
|
||||
KEYWORDS = [
|
||||
"iran", "iranian", "tehran", "以色列", "israel",
|
||||
"usa", "us ", "american", "美军", "美国",
|
||||
"middle east", "中东", "persian gulf", "波斯湾",
|
||||
"strike", "attack", "military", "missile", "核", "nuclear",
|
||||
"carrier", "航母", "houthi", "胡塞", "hamas",
|
||||
]
|
||||
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()
|
||||
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()
|
||||
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"
|
||||
286
crawler/realtime_conflict_service.py
Normal file
286
crawler/realtime_conflict_service.py
Normal file
@@ -0,0 +1,286 @@
|
||||
# -*- 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 requests
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
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", "45")) # 新闻抓取更频繁,优先保证事件脉络
|
||||
# 时间范围: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
|
||||
title = translate_to_chinese(str(title_raw)[:500])
|
||||
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 as e:
|
||||
print(f"GDELT 抓取失败: {e}")
|
||||
|
||||
|
||||
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)
|
||||
# ==========================
|
||||
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
|
||||
items = fetch_all()
|
||||
for it in items:
|
||||
it["title"] = translate_to_chinese(it.get("title", "") or "")
|
||||
it["summary"] = translate_to_chinese(it.get("summary", "") or it.get("title", ""))
|
||||
if items:
|
||||
n = write_updates(items)
|
||||
if n > 0:
|
||||
_notify_node()
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] 新闻入库 {n} 条")
|
||||
except Exception as e:
|
||||
print(f"新闻抓取失败: {e}")
|
||||
|
||||
|
||||
# ==========================
|
||||
# 定时任务(RSS 更频繁,优先保证事件脉络实时)
|
||||
# ==========================
|
||||
scheduler = BackgroundScheduler()
|
||||
scheduler.add_job(fetch_news, "interval", seconds=RSS_INTERVAL_SEC)
|
||||
scheduler.add_job(fetch_gdelt_events, "interval", seconds=FETCH_INTERVAL_SEC)
|
||||
scheduler.start()
|
||||
|
||||
|
||||
# ==========================
|
||||
# API 接口
|
||||
# ==========================
|
||||
@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.
77
crawler/scrapers/rss_scraper.py
Normal file
77
crawler/scrapers/rss_scraper.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""RSS 抓取"""
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import feedparser
|
||||
|
||||
from config import RSS_FEEDS, KEYWORDS
|
||||
from parser import classify, 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 = classify(text)
|
||||
sev = severity(text, cat)
|
||||
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
|
||||
28
crawler/translate_utils.py
Normal file
28
crawler/translate_utils.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""英译中,入库前统一翻译"""
|
||||
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:
|
||||
"""将文本翻译成中文,失败或已是中文则返回原文。"""
|
||||
if not text or not text.strip():
|
||||
return text
|
||||
s = str(text).strip()
|
||||
if len(s) > 2000:
|
||||
s = s[:2000]
|
||||
if _is_mostly_chinese(s):
|
||||
return text
|
||||
try:
|
||||
from deep_translator import GoogleTranslator
|
||||
out = GoogleTranslator(source="auto", target="zh-CN").translate(s)
|
||||
return out if out else text
|
||||
except Exception:
|
||||
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
|
||||
```
|
||||
89
src/components/EventTimelinePanel.tsx
Normal file
89
src/components/EventTimelinePanel.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { SituationUpdate, ConflictEvent } from '@/data/mockData'
|
||||
import { History } from 'lucide-react'
|
||||
|
||||
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) {
|
||||
// 合并 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, 6)
|
||||
|
||||
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>
|
||||
<span className="text-[8px] text-military-text-secondary/80">GDELT · Reuters · BBC · Al Jazeera · NYT</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
143
src/hooks/useReplaySituation.ts
Normal file
143
src/hooks/useReplaySituation.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
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 civUs = usLoss.civilianCasualties ?? { killed: 0, wounded: 0 }
|
||||
const civIr = irLoss.civilianCasualties ?? { 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: lerp(0, civUs.killed), wounded: lerp(0, civUs.wounded) },
|
||||
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: lerp(0, civIr.killed), wounded: lerp(0, civIr.wounded) },
|
||||
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,
|
||||
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])
|
||||
}
|
||||
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 }),
|
||||
}))
|
||||
Reference in New Issue
Block a user