diff --git a/.env.example b/.env.example index 87cbb57..1c1ca53 100644 --- a/.env.example +++ b/.env.example @@ -2,45 +2,3 @@ # 免费申请: https://account.mapbox.com/access-tokens/ # 复制本文件为 .env 并填入你的 token VITE_MAPBOX_ACCESS_TOKEN=your_mapbox_public_token_here -27 个基地完整 JSON 数据 -[ - { "id": 1, "name": "Al Udeid Air Base", "country": "Qatar", "lat": 25.117, "lng": 51.314 }, - { "id": 2, "name": "Camp As Sayliyah", "country": "Qatar", "lat": 25.275, "lng": 51.520 }, - - { "id": 3, "name": "Naval Support Activity Bahrain", "country": "Bahrain", "lat": 26.236, "lng": 50.608 }, - - { "id": 4, "name": "Camp Arifjan", "country": "Kuwait", "lat": 28.832, "lng": 47.799 }, - { "id": 5, "name": "Ali Al Salem Air Base", "country": "Kuwait", "lat": 29.346, "lng": 47.520 }, - { "id": 6, "name": "Camp Buehring", "country": "Kuwait", "lat": 29.603, "lng": 47.456 }, - - { "id": 7, "name": "Al Dhafra Air Base", "country": "UAE", "lat": 24.248, "lng": 54.547 }, - - { "id": 8, "name": "Prince Sultan Air Base", "country": "Saudi Arabia", "lat": 24.062, "lng": 47.580 }, - { "id": 9, "name": "Eskan Village", "country": "Saudi Arabia", "lat": 24.774, "lng": 46.738 }, - - { "id": 10, "name": "Al Asad Airbase", "country": "Iraq", "lat": 33.785, "lng": 42.441 }, - { "id": 11, "name": "Erbil Air Base", "country": "Iraq", "lat": 36.237, "lng": 43.963 }, - { "id": 12, "name": "Baghdad Diplomatic Support Center", "country": "Iraq", "lat": 33.315, "lng": 44.366 }, - { "id": 13, "name": "Camp Taji", "country": "Iraq", "lat": 33.556, "lng": 44.256 }, - { "id": 14, "name": "Ain al-Asad", "country": "Iraq", "lat": 33.800, "lng": 42.450 }, - - { "id": 15, "name": "Al-Tanf Garrison", "country": "Syria", "lat": 33.490, "lng": 38.618 }, - { "id": 16, "name": "Rmelan Landing Zone", "country": "Syria", "lat": 37.015, "lng": 41.885 }, - { "id": 17, "name": "Shaddadi Base", "country": "Syria", "lat": 36.058, "lng": 40.730 }, - { "id": 18, "name": "Conoco Gas Field Base", "country": "Syria", "lat": 35.336, "lng": 40.295 }, - - { "id": 19, "name": "Muwaffaq Salti Air Base", "country": "Jordan", "lat": 32.356, "lng": 36.259 }, - - { "id": 20, "name": "Incirlik Air Base", "country": "Turkey", "lat": 37.002, "lng": 35.425 }, - { "id": 21, "name": "Kurecik Radar Station", "country": "Turkey", "lat": 38.354, "lng": 37.794 }, - - { "id": 22, "name": "Nevatim Air Base", "country": "Israel", "lat": 31.208, "lng": 35.012 }, - { "id": 23, "name": "Ramon Air Base", "country": "Israel", "lat": 30.776, "lng": 34.666 }, - - { "id": 24, "name": "Thumrait Air Base", "country": "Oman", "lat": 17.666, "lng": 54.024 }, - { "id": 25, "name": "Masirah Air Base", "country": "Oman", "lat": 20.675, "lng": 58.890 }, - - { "id": 26, "name": "West Cairo Air Base", "country": "Egypt", "lat": 30.915, "lng": 30.298 }, - - { "id": 27, "name": "Camp Lemonnier", "country": "Djibouti", "lat": 11.547, "lng": 43.159 } -] \ No newline at end of file diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..380053b --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,111 @@ +# Docker 部署到服务器 + +将 US-Iran 态势面板打包成 Docker 镜像,便于移植到任意服务器。 + +## 架构 + +| 服务 | 端口 | 说明 | +|--------|------|--------------------------| +| api | 3001 | 前端静态 + REST API + WebSocket | +| crawler| 8000 | RSS 爬虫 + GDELT,内部服务 | + +- 数据库:SQLite,挂载到 `app-data` volume(`/data/data.db`) +- 前端与 API 合并到同一镜像,访问 `http://主机:3001` 即可 + +## 快速部署 + +```bash +# 1. 克隆项目 +git clone usa-dashboard && cd usa-dashboard + +# 2. 构建并启动(需先配置 Mapbox Token,见下方) +docker compose up -d --build + +# 3. 访问 +# 前端 + API: http://localhost:3001 +# 爬虫状态: http://localhost:8000/crawler/status +``` + +## Mapbox Token(地图展示) + +构建时需将 Token 传入前端,否则地图为占位模式: + +```bash +# 方式 1:.env 文件 +echo "VITE_MAPBOX_ACCESS_TOKEN=pk.xxx" > .env +docker compose up -d --build + +# 方式 2:环境变量 +VITE_MAPBOX_ACCESS_TOKEN=pk.xxx docker compose up -d --build +``` + +## 推送到私有仓库并移植 + +```bash +# 1. 打标签(替换为你的仓库地址) +docker compose build +docker tag usa-dashboard-api your-registry/usa-dashboard-api:latest +docker tag usa-dashboard-crawler your-registry/usa-dashboard-crawler:latest + +# 2. 推送 +docker push your-registry/usa-dashboard-api:latest +docker push your-registry/usa-dashboard-crawler:latest + +# 3. 在目标服务器拉取并启动 +docker pull your-registry/usa-dashboard-api:latest +docker pull your-registry/usa-dashboard-crawler:latest +# 需准备 docker-compose.yml 或等效编排,见下方 +``` + +## 仅用镜像启动(无 compose) + +```bash +# 1. 创建网络与数据卷 +docker network create usa-net +docker volume create usa-data + +# 2. 启动 API(前端+接口) +docker run -d --name api --network usa-net \ + -p 3001:3001 \ + -v usa-data:/data \ + -e DB_PATH=/data/data.db \ + usa-dashboard-api + +# 3. 启动爬虫(通过 usa-net 访问 api) +docker run -d --name crawler --network usa-net \ + -v usa-data:/data \ + -e DB_PATH=/data/data.db \ + -e API_BASE=http://api:3001 \ + -e CLEANER_AI_DISABLED=1 \ + -e GDELT_DISABLED=1 \ + usa-dashboard-crawler +``` + +爬虫通过 `API_BASE` 调用 Node 的 `/api/crawler/notify`,两容器需在同一网络内。 + +## 国内服务器 / 镜像加速 + +拉取 `node`、`python` 等基础镜像慢时: + +1. **Docker 镜像加速**:见 [docs/DOCKER_MIRROR.md](docs/DOCKER_MIRROR.md) +2. **构建时使用国内镜像源**: + ```bash + docker compose build --build-arg REGISTRY=docker.m.daocloud.io/library/ + docker compose up -d + ``` + +## 常用操作 + +```bash +# 查看日志 +docker compose logs -f + +# 重启 +docker compose restart + +# 停止并删除容器(数据卷保留) +docker compose down + +# 回填战损数据(从 situation_update 重新提取) +curl -X POST http://localhost:8000/crawler/backfill +``` diff --git a/Dockerfile b/Dockerfile index 1f39cd5..1c42bad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ -# 前端 + API 一体化镜像(使用 DaoCloud 国内镜像源) -FROM docker.m.daocloud.io/library/node:20-alpine AS frontend-builder +# 前端 + API 一体化镜像 +# 国内服务器拉取慢时,可加 --build-arg REGISTRY=docker.m.daocloud.io/library +ARG REGISTRY= +FROM ${REGISTRY}node:20-alpine AS frontend-builder WORKDIR /app ARG VITE_MAPBOX_ACCESS_TOKEN ENV VITE_MAPBOX_ACCESS_TOKEN=${VITE_MAPBOX_ACCESS_TOKEN} @@ -9,7 +11,7 @@ RUN npm ci COPY . . RUN npm run build -FROM docker.m.daocloud.io/library/node:20-alpine +FROM ${REGISTRY}node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev diff --git a/Dockerfile.crawler b/Dockerfile.crawler index c975465..c1907fc 100644 --- a/Dockerfile.crawler +++ b/Dockerfile.crawler @@ -1,9 +1,11 @@ -# Python 爬虫服务(使用 DaoCloud 国内镜像源 + 清华 PyPI 源) -FROM docker.m.daocloud.io/library/python:3.11-slim +# Python 爬虫服务 +# 国内服务器可加 --build-arg REGISTRY=docker.m.daocloud.io/library +ARG REGISTRY= +FROM ${REGISTRY}python:3.11-slim WORKDIR /app COPY crawler/requirements.txt ./ -RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt +RUN pip install --no-cache-dir -r requirements.txt COPY crawler ./ ENV DB_PATH=/data/data.db diff --git a/README.md b/README.md index d070f40..438ca22 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,12 @@ API 会由 Vite 代理到 `/api`,前端通过 `/api/situation` 获取完整态 3. 查看爬虫状态:`curl http://localhost:8000/crawler/status`(需爬虫服务已启动) 4. 数据库面板 `/db` 每 30 秒自动刷新,可观察 situation_update 条数是否增加 +### 面板数据 / 地图 / 战损不更新时 + +- **确保 API 与爬虫共用同一数据库**:本地开发时,Node 默认用 `server/data.db`,爬虫默认用 `../server/data.db`(同文件)。若 Node 在本地、爬虫在 Docker,则数据库不同,面板不会更新。 +- **Docker 部署**:`GDELT_DISABLED=1` 时,地图冲突点由 RSS 新闻填充;战损与基地状态由规则/AI 提取后写入 `combat_losses` 和 `key_location`。 +- **排查**:访问 `/db` 看 `situation_update`、`gdelt_events`、`combat_losses` 是否在增长;确认 API 已启动且前端能访问 `/api/situation`。 + ## Development ```bash @@ -88,6 +94,8 @@ docker compose up -d # 数据库与爬虫共享 volume,首次启动自动 seed ``` +**迁移到服务器**:见 [DEPLOY.md](DEPLOY.md)(构建、推送、单机/多机部署说明) + **拉取镜像超时?** 在 Docker Desktop 配置镜像加速,见 [docs/DOCKER_MIRROR.md](docs/DOCKER_MIRROR.md) **开发时无需每次 rebuild**:使用开发模式挂载源码 + 热重载: diff --git a/crawler/__pycache__/db_merge.cpython-311.pyc b/crawler/__pycache__/db_merge.cpython-311.pyc new file mode 100644 index 0000000..bb79ac2 Binary files /dev/null and b/crawler/__pycache__/db_merge.cpython-311.pyc differ diff --git a/crawler/__pycache__/extractor_rules.cpython-311.pyc b/crawler/__pycache__/extractor_rules.cpython-311.pyc new file mode 100644 index 0000000..26068eb Binary files /dev/null and b/crawler/__pycache__/extractor_rules.cpython-311.pyc differ diff --git a/crawler/__pycache__/extractor_rules.cpython-39.pyc b/crawler/__pycache__/extractor_rules.cpython-39.pyc index b3358bb..64d4792 100644 Binary files a/crawler/__pycache__/extractor_rules.cpython-39.pyc and b/crawler/__pycache__/extractor_rules.cpython-39.pyc differ diff --git a/crawler/__pycache__/realtime_conflict_service.cpython-311.pyc b/crawler/__pycache__/realtime_conflict_service.cpython-311.pyc index ef0d3a0..39a8ba6 100644 Binary files a/crawler/__pycache__/realtime_conflict_service.cpython-311.pyc and b/crawler/__pycache__/realtime_conflict_service.cpython-311.pyc differ diff --git a/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc b/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc index 716af21..438cbaf 100644 Binary files a/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc and b/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc differ diff --git a/crawler/extractor_rules.py b/crawler/extractor_rules.py index 89a1ee4..e067349 100644 --- a/crawler/extractor_rules.py +++ b/crawler/extractor_rules.py @@ -25,7 +25,24 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A loss_us, loss_ir = {}, {} - # 美军人员伤亡 + # 美军人员伤亡(中文,优先匹配) + v = _first_int(t, r"造成\s*(\d+)\s*名?\s*美军\s*伤亡") + if v is not None: + loss_us["personnel_killed"] = v + v = _first_int(t, r"(\d+)\s*名?\s*美军\s*伤亡") if loss_us.get("personnel_killed") is None else None + if v is not None: + loss_us["personnel_killed"] = v + v = _first_int(t, r"(\d+)\s*名?\s*(?:美军|美国军队|美国)\s*(?:死亡|阵亡)") + if v is not None: + loss_us["personnel_killed"] = v + v = _first_int(t, r"(\d+)\s*名?\s*(?:美军|美国)\s*受伤") + if v is not None: + loss_us["personnel_wounded"] = v + v = _first_int(t, r"美军\s*伤亡\s*(\d+)") + if v is not None and loss_us.get("personnel_killed") is None: + loss_us["personnel_killed"] = v + + # 美军人员伤亡(英文) v = _first_int(t, r"(?:us|american|u\.?s\.?)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|military)[\s\w]*(?:killed|dead)") if v is not None: loss_us["personnel_killed"] = v @@ -36,7 +53,18 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A if v is not None: loss_us["personnel_wounded"] = v - # 伊朗人员伤亡 + # 伊朗人员伤亡(中文) + v = _first_int(t, r"(\d+)\s*名?\s*伊朗\s*伤亡") + if v is not None: + loss_ir["personnel_killed"] = v + v = _first_int(t, r"(\d+)\s*名?\s*(?:伊朗|伊朗军队)\s*(?:死亡|阵亡)") + if v is not None: + loss_ir["personnel_killed"] = v + v = _first_int(t, r"(\d+)\s*名?\s*伊朗\s*受伤") + if v is not None: + loss_ir["personnel_wounded"] = v + + # 伊朗人员伤亡(英文) v = _first_int(t, r"(?:iran|iranian)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|guard|killed|dead)") if v is not None: loss_ir["personnel_killed"] = v @@ -47,24 +75,27 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A if v is not None: loss_ir["personnel_wounded"] = v - # 平民伤亡(多不区分阵营,计入双方或仅 us 因多为美国基地周边) - v = _first_int(t, r"(\d+)[\s\w]*(?:civilian|civil)[\s\w]*(?:killed|dead)") + # 平民伤亡(中英文) + v = _first_int(t, r"(\d+)\s*名?\s*平民\s*(?:伤亡|死亡)") + if v is not None: + loss_us["civilian_killed"] = v + v = _first_int(t, r"(\d+)[\s\w]*(?:civilian|civil)[\s\w]*(?:killed|dead)") if loss_us.get("civilian_killed") is None else None if v is not None: loss_us["civilian_killed"] = v v = _first_int(t, r"(\d+)[\s\w]*(?:civilian|civil)[\s\w]*(?:wounded|injured)") if v is not None: loss_us["civilian_wounded"] = v - # 基地损毁(美方基地居多) - v = _first_int(t, r"(\d+)[\s\w]*(?:base)[\s\w]*(?:destroyed|leveled)") + # 基地损毁(美方基地居多)+ 中文 + v = _first_int(t, r"(\d+)[\s\w]*(?:base|基地)[\s\w]*(?:destroyed|leveled|摧毁|夷平)") if v is not None: loss_us["bases_destroyed"] = v - v = _first_int(t, r"(\d+)[\s\w]*(?:base)[\s\w]*(?:damaged|hit|struck)") + v = _first_int(t, r"(\d+)[\s\w]*(?:base|基地)[\s\w]*(?:damaged|hit|struck|受损|袭击)") if v is not None: loss_us["bases_damaged"] = v - if "base" in t and ("destroy" in t or "level" in t) and not loss_us.get("bases_destroyed"): + if ("base" in t or "基地" in t) and ("destroy" in t or "level" in t or "摧毁" in t or "夷平" in t) and not loss_us.get("bases_destroyed"): loss_us["bases_destroyed"] = 1 - if "base" in t and ("damage" in t or "hit" in t or "struck" in t or "strike" in t) and not loss_us.get("bases_damaged"): + if ("base" in t or "基地" in t) and ("damage" in t or "hit" in t or "struck" in t or "strike" in t or "袭击" in t or "受损" in t) and not loss_us.get("bases_damaged"): loss_us["bases_damaged"] = 1 # 战机 / 舰船(根据上下文判断阵营) @@ -87,9 +118,48 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A 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: + if "retaliat" in t or "revenge" in t or "报复" 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} + # key_location_updates:受袭基地(与 key_location.name 匹配) + # 新闻提及基地遭袭时,更新对应基地 status + base_attacked = ("base" in t or "基地" in t) and ("attack" in t or "hit" in t or "strike" in t or "damage" in t or "袭击" in t or "打击" in t) + if base_attacked: + updates: list = [] + # 常见美军基地关键词 -> name_keywords(用于 db_merge 的 LIKE 匹配) + bases_all = [ + ("阿萨德|阿因|asad|assad|ain", "us"), + ("巴格达|baghdad", "us"), + ("乌代德|udeid|卡塔尔|qatar", "us"), + ("阿克罗蒂里|akrotiri|塞浦路斯|cyprus", "us"), + ("巴格拉姆|bagram|阿富汗|afghanistan", "us"), + ("埃尔比勒|erbil", "us"), + ("因吉尔利克|incirlik|土耳其|turkey", "us"), + ("苏尔坦|sultan|沙特|saudi", "us"), + ("坦夫|tanf|叙利亚|syria", "us"), + ("达夫拉|dhafra|阿联酋|uae", "us"), + ("内瓦提姆|nevatim|拉蒙|ramon|以色列|israel", "us"), + ("赛利耶|sayliyah", "us"), + ("巴林|bahrain", "us"), + ("科威特|kuwait", "us"), + # 伊朗基地 + ("阿巴斯港|abbas|bandar abbas", "iran"), + ("德黑兰|tehran", "iran"), + ("布什尔|bushehr", "iran"), + ("伊斯法罕|isfahan|esfahan", "iran"), + ("纳坦兹|natanz", "iran"), + ("米纳布|minab", "iran"), + ("卡拉季|karaj", "iran"), + ("克尔曼沙赫|kermanshah", "iran"), + ("大不里士|tabriz", "iran"), + ("霍尔木兹|hormuz", "iran"), + ] + for kws, side in bases_all: + if any(k in t for k in kws.split("|")): + updates.append({"name_keywords": kws, "side": side, "status": "attacked", "damage_level": 2}) + if updates: + out["key_location_updates"] = updates + return out diff --git a/crawler/realtime_conflict_service.py b/crawler/realtime_conflict_service.py index bae1de7..76b87c4 100644 --- a/crawler/realtime_conflict_service.py +++ b/crawler/realtime_conflict_service.py @@ -54,23 +54,56 @@ EVENT_CACHE: List[dict] = [] def calculate_impact_score(title: str) -> int: score = 1 t = (title or "").lower() - if "missile" in t: + if "missile" in t or "导弹" in t: score += 3 - if "strike" in t: + if "strike" in t or "袭击" in t or "打击" in t: score += 2 - if "killed" in t or "death" in t or "casualt" in t: + if "killed" in t or "death" in t or "casualt" in t or "死亡" in t or "伤亡" in t: score += 4 - if "troops" in t or "soldier" in t: + if "troops" in t or "soldier" in t or "士兵" in t or "军人" in t: score += 2 - if "attack" in t or "attacked" in t: + if "attack" in t or "attacked" in t or "攻击" 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: + if "explosion" in t or "blast" in t or "bomb" in t or "爆炸" in t: score += 2 return min(score, 10) +# 根据 severity 映射到 impact_score +def _severity_to_score(sev: str) -> int: + m = {"critical": 9, "high": 7, "medium": 5, "low": 2} + return m.get((sev or "").lower(), 5) + + +# 根据文本推断坐标 [lng, lat],用于 GDELT 禁用时 RSS→gdelt_events +_LOC_COORDS = [ + (["阿克罗蒂里", "akrotiri", "塞浦路斯", "cyprus"], (32.98, 34.58)), + (["巴格拉姆", "bagram", "阿富汗", "afghanistan"], (69.26, 34.95)), + (["巴格达", "baghdad", "伊拉克", "iraq"], (44.37, 33.31)), + (["贝鲁特", "beirut", "黎巴嫩", "lebanon"], (35.49, 33.89)), + (["耶路撒冷", "jerusalem", "特拉维夫", "tel aviv", "以色列", "israel"], (35.21, 31.77)), + (["阿巴斯港", "bandar abbas", "霍尔木兹", "hormuz"], (56.27, 27.18)), + (["米纳布", "minab"], (57.08, 27.13)), + (["德黑兰", "tehran", "伊朗", "iran"], (51.389, 35.689)), + (["大马士革", "damascus", "叙利亚", "syria"], (36.28, 33.50)), + (["迪拜", "dubai", "阿联酋", "uae"], (55.27, 25.20)), + (["沙特", "saudi"], (46.73, 24.71)), + (["巴基斯坦", "pakistan"], (73.06, 33.72)), + (["奥斯汀", "austin"], (-97.74, 30.27)), +] + + +def _infer_coords(text: str) -> tuple: + t = (text or "").lower() + for kws, (lng, lat) in _LOC_COORDS: + for k in kws: + if k in t: + return (lng, lat) + return (IRAN_COORD[0], IRAN_COORD[1]) + + # ========================== # 获取 GDELT 实时事件 # ========================== @@ -216,6 +249,39 @@ def _notify_node() -> None: print(f" [warn] notify API: {e}") +def _rss_to_gdelt_fallback() -> None: + """GDELT 禁用时,将 situation_update 同步到 gdelt_events,使地图有冲突点""" + if not GDELT_DISABLED or not os.path.exists(DB_PATH): + return + try: + conn = sqlite3.connect(DB_PATH, timeout=10) + rows = conn.execute( + "SELECT id, timestamp, category, summary, severity FROM situation_update ORDER BY timestamp DESC LIMIT 50" + ).fetchall() + conn.close() + events = [] + for r in rows: + uid, ts, cat, summary, sev = r + lng, lat = _infer_coords((summary or "")[:300]) + impact = _severity_to_score(sev) + events.append({ + "event_id": f"rss_{uid}", + "event_time": ts, + "title": (summary or "")[:500], + "lat": lat, + "lng": lng, + "impact_score": impact, + "url": "", + }) + if events: + global EVENT_CACHE + EVENT_CACHE = events + _write_to_db(events) + _notify_node() + except Exception as e: + print(f" [warn] RSS→gdelt fallback: {e}") + + # ========================== # RSS 新闻抓取(补充 situation_update + AI 提取面板数据) # ========================== @@ -243,6 +309,9 @@ def fetch_news() -> None: LAST_FETCH["inserted"] = n if items: _extract_and_merge_panel_data(items) + # GDELT 禁用时用 RSS 填充 gdelt_events,使地图有冲突点 + if GDELT_DISABLED: + _rss_to_gdelt_fallback() # 每次抓取完成都通知 Node 更新时间戳,便于「实时更新」显示 _notify_node() print(f"[{datetime.now().strftime('%H:%M:%S')}] RSS 抓取 {len(items)} 条,新增入库 {n} 条") @@ -264,7 +333,7 @@ def _extract_and_merge_panel_data(items: list) -> None: from datetime import timezone merged_any = False # 规则模式可多处理几条(无 Ollama);AI 模式限制 5 条避免调用过多 - limit = 10 if os.environ.get("CLEANER_AI_DISABLED", "0") == "1" else 5 + limit = 25 if os.environ.get("CLEANER_AI_DISABLED", "0") == "1" else 10 for it in items[:limit]: text = (it.get("title", "") or "") + " " + (it.get("summary", "") or "") if len(text.strip()) < 20: @@ -314,6 +383,40 @@ async def _periodic_fetch() -> None: # ========================== # API 接口 # ========================== +@app.post("/crawler/backfill") +def crawler_backfill(): + """从 situation_update 重新解析并合并战损/报复等数据,用于修复历史数据未提取的情况""" + if not os.path.exists(DB_PATH): + return {"ok": False, "error": "db not found"} + 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 + conn = sqlite3.connect(DB_PATH, timeout=10) + rows = conn.execute( + "SELECT id, timestamp, category, summary FROM situation_update ORDER BY timestamp DESC LIMIT 50" + ).fetchall() + conn.close() + merged = 0 + for r in rows: + uid, ts, cat, summary = r + text = ((cat or "") + " " + (summary or "")).strip() + if len(text) < 20: + continue + try: + extracted = extract_from_news(text, timestamp=ts) + if extracted and merge(extracted, db_path=DB_PATH): + merged += 1 + except Exception: + pass + _notify_node() + return {"ok": True, "processed": len(rows), "merged": merged} + except Exception as e: + return {"ok": False, "error": str(e)} + + @app.get("/crawler/status") def crawler_status(): """爬虫状态:用于排查数据更新链路""" diff --git a/docker-compose.yml b/docker-compose.yml index bb3677c..9d0dbc9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,6 @@ services: api: + image: usa-dashboard-api:latest build: context: . args: @@ -14,6 +15,7 @@ services: restart: unless-stopped crawler: + image: usa-dashboard-crawler:latest build: context: . dockerfile: Dockerfile.crawler diff --git a/package-lock.json b/package-lock.json index 70559b0..22edd82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "express": "^4.21.1", "lucide-react": "^0.460.0", "mapbox-gl": "^3.6.0", + "opencc-js": "^1.0.5", "react": "^18.3.1", "react-dom": "^18.3.1", "react-map-gl": "^7.1.7", @@ -3885,6 +3886,11 @@ "wrappy": "1" } }, + "node_modules/opencc-js": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/opencc-js/-/opencc-js-1.0.5.tgz", + "integrity": "sha512-LD+1SoNnZdlRwtYTjnQdFrSVCAaYpuDqL5CkmOaHOkKoKh7mFxUicLTRVNLU5C+Jmi1vXQ3QL4jWdgSaa4sKjg==" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index 3190439..c977cb4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "express": "^4.21.1", "lucide-react": "^0.460.0", "mapbox-gl": "^3.6.0", + "opencc-js": "^1.0.5", "react": "^18.3.1", "react-dom": "^18.3.1", "react-map-gl": "^7.1.7", diff --git a/server/data.db b/server/data.db index ee619b0..0ecd106 100644 Binary files a/server/data.db and b/server/data.db differ diff --git a/server/data.db-shm b/server/data.db-shm index db40834..4829ca3 100644 Binary files a/server/data.db-shm and b/server/data.db-shm differ diff --git a/server/data.db-wal b/server/data.db-wal index 1bd86b3..dc7ce8c 100644 Binary files a/server/data.db-wal and b/server/data.db-wal differ diff --git a/server/seed.js b/server/seed.js index ffbd74a..83a70be 100644 --- a/server/seed.js +++ b/server/seed.js @@ -134,9 +134,16 @@ function seed() { insertLoc.run('us', loc.name, loc.lat, loc.lng, loc.type, loc.region, loc.status, loc.damage_level) } const iranLocs = [ - ['iran', '阿巴斯港', 27.1832, 56.2666, 'Port', '伊朗', null, null], - ['iran', '德黑兰', 35.6892, 51.389, 'Capital', '伊朗', null, null], - ['iran', '布什尔', 28.9681, 50.838, 'Base', '伊朗', null, null], + ['iran', '阿巴斯港海军司令部', 27.18, 56.27, 'Port', '伊朗', 'attacked', 3], + ['iran', '德黑兰', 35.6892, 51.389, 'Capital', '伊朗', 'attacked', 3], + ['iran', '布什尔核电站', 28.9681, 50.838, 'Nuclear', '伊朗', 'attacked', 2], + ['iran', '伊斯法罕核设施', 32.654, 51.667, 'Nuclear', '伊朗', 'attacked', 2], + ['iran', '纳坦兹铀浓缩', 33.666, 51.916, 'Nuclear', '伊朗', 'attacked', 2], + ['iran', '米纳布岸防', 27.13, 57.08, 'Base', '伊朗', 'damaged', 2], + ['iran', '卡拉季无人机厂', 35.808, 51.002, 'Base', '伊朗', 'attacked', 2], + ['iran', '克尔曼沙赫导弹阵地', 34.314, 47.076, 'Missile', '伊朗', 'attacked', 2], + ['iran', '大不里士空军基地', 38.08, 46.29, 'Base', '伊朗', 'damaged', 1], + ['iran', '霍尔木兹岸防阵地', 27.0, 56.5, 'Base', '伊朗', 'operational', null], ] iranLocs.forEach((r) => insertLoc.run(...r)) diff --git a/server/situationData.js b/server/situationData.js index d23b54a..62cd1b9 100644 --- a/server/situationData.js +++ b/server/situationData.js @@ -41,7 +41,7 @@ function getSituation() { const assetsUs = db.prepare('SELECT * FROM force_asset WHERE side = ? ORDER BY id').all('us') const assetsIr = db.prepare('SELECT * FROM force_asset WHERE side = ? ORDER BY id').all('iran') const locUs = db.prepare('SELECT id, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('us') - const locIr = db.prepare('SELECT id, name, lat, lng, type, region FROM key_location WHERE side = ?').all('iran') + const locIr = db.prepare('SELECT id, name, lat, lng, type, region, status, damage_level FROM key_location WHERE side = ?').all('iran') const lossesUs = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('us') const lossesIr = db.prepare('SELECT * FROM combat_losses WHERE side = ?').get('iran') const trend = db.prepare('SELECT time, value FROM wall_street_trend ORDER BY time').all() diff --git a/src/components/IranBaseStatusPanel.tsx b/src/components/IranBaseStatusPanel.tsx new file mode 100644 index 0000000..464f9b8 --- /dev/null +++ b/src/components/IranBaseStatusPanel.tsx @@ -0,0 +1,72 @@ +import { useMemo } from 'react' +import { MapPin, AlertTriangle, AlertCircle } from 'lucide-react' +import type { MilitarySituation } from '@/data/mockData' + +interface IranBaseStatusPanelProps { + keyLocations: MilitarySituation['iranForces']['keyLocations'] + className?: string +} + +export function IranBaseStatusPanel({ keyLocations = [], className = '' }: IranBaseStatusPanelProps) { + const stats = useMemo(() => { + const bases = (keyLocations || []).filter((loc) => loc.type === 'Base' || loc.type === 'Port' || loc.type === 'Nuclear' || loc.type === 'Missile') + let attacked = 0 + let severe = 0 + let moderate = 0 + let light = 0 + for (const b of bases) { + const s = b.status ?? 'operational' + if (s === 'attacked') attacked++ + const lvl = b.damage_level + if (lvl === 3) severe++ + else if (lvl === 2) moderate++ + else if (lvl === 1) light++ + } + return { total: bases.length, attacked, severe, moderate, light } + }, [keyLocations]) + + return ( +
+
+ + 伊朗基地态势 +
+
+
+ 总基地数 + {stats.total} +
+
+ + + 被袭击 + + {stats.attacked} +
+
+ + + 严重损毁 + + {stats.severe} +
+
+ + + 中度损毁 + + {stats.moderate} +
+
+ + + 轻度损毁 + + {stats.light} +
+
+
+ ) +} diff --git a/src/components/NewsTicker.tsx b/src/components/NewsTicker.tsx new file mode 100644 index 0000000..79df1b0 --- /dev/null +++ b/src/components/NewsTicker.tsx @@ -0,0 +1,56 @@ +import { useMemo } from 'react' +import type { SituationUpdate, ConflictEvent } from '@/data/mockData' +import { processTickerText } from '@/utils/tickerText' + +interface NewsTickerProps { + updates?: SituationUpdate[] + conflictEvents?: ConflictEvent[] + className?: string +} + +export function NewsTicker({ updates = [], conflictEvents = [], className = '' }: NewsTickerProps) { + const items = useMemo(() => { + const list: { id: string; text: string }[] = [] + for (const e of conflictEvents || []) { + const text = processTickerText(e.title || '') + if (text) list.push({ id: `ev-${e.event_id}`, text }) + } + for (const u of updates || []) { + const text = processTickerText(u.summary || '') + if (text) list.push({ id: `up-${u.id}`, text }) + } + return list.slice(0, 30) + }, [updates, conflictEvents]) + + const baseCls = 'flex items-center overflow-hidden' + const defaultCls = 'border-b border-military-border/50 bg-military-panel/60 py-1.5' + const wrapperCls = className ? `${baseCls} ${className}` : `${baseCls} ${defaultCls}` + + if (items.length === 0) { + return ( +
+ 滚动情报 + 暂无资讯 +
+ ) + } + + const content = items.map((i) => i.text).join(' ◆ ') + const duration = Math.max(180, Math.min(480, content.length * 0.8)) + + return ( +
+ 滚动情报 +
+
+ {content} + + {content} +
+
+
+ ) +} diff --git a/src/components/TimelinePanel.tsx b/src/components/TimelinePanel.tsx index 8705ae1..cdd4f4e 100644 --- a/src/components/TimelinePanel.tsx +++ b/src/components/TimelinePanel.tsx @@ -1,6 +1,9 @@ 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' +import { useSituationStore } from '@/store/situationStore' +import { NewsTicker } from './NewsTicker' +import { config } from '@/config' function formatTick(iso: string): string { const d = new Date(iso) @@ -14,6 +17,7 @@ function formatTick(iso: string): string { } export function TimelinePanel() { + const situation = useSituationStore((s) => s.situation) const { isReplayMode, playbackTime, @@ -75,6 +79,16 @@ export function TimelinePanel() { 数据回放 + {!isReplayMode && config.showNewsTicker && ( +
+ +
+ )} + {isReplayMode && ( <>
diff --git a/src/components/WarMap.tsx b/src/components/WarMap.tsx index 3d0dc9a..e6a008b 100644 --- a/src/components/WarMap.tsx +++ b/src/components/WarMap.tsx @@ -4,6 +4,7 @@ import type { MapRef } from 'react-map-gl' import type { Map as MapboxMap } from 'mapbox-gl' import 'mapbox-gl/dist/mapbox-gl.css' import { useReplaySituation } from '@/hooks/useReplaySituation' +import { config } from '@/config' import { ATTACKED_TARGETS, ALLIED_STRIKE_LOCATIONS, @@ -15,7 +16,7 @@ import { ISRAEL_STRIKE_TARGETS, } from '@/data/mapLocations' -const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN || '' +const MAPBOX_TOKEN = config.mapboxAccessToken || '' // 相关区域 bbox:伊朗、以色列、胡塞区 (minLng, minLat, maxLng, maxLat),覆盖红蓝区域 const THEATER_BBOX = [22, 11, 64, 41] as const diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..7f2ba45 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,10 @@ +/** + * 应用配置(不依赖 .env) + */ +export const config = { + /** Mapbox 地图令牌 */ + mapboxAccessToken: + 'pk.eyJ1IjoiZDI5cTAiLCJhIjoiY21oaGRmcTkzMGltZzJscHR1N2FhZnY5dCJ9.7ueF2lS6-C9Mm_xon7NnIA', + /** 是否显示滚动情报 */ + showNewsTicker: false, +} diff --git a/src/index.css b/src/index.css index b1607aa..4b194f5 100644 --- a/src/index.css +++ b/src/index.css @@ -53,3 +53,9 @@ body, background: rgba(75, 85, 99, 0.5); border-radius: 2px; } + +@keyframes ticker { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} + diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 72eea8c..1d5f74f 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -4,7 +4,7 @@ import { TimelinePanel } from '@/components/TimelinePanel' import { ForcePanel } from '@/components/ForcePanel' import { WarMap } from '@/components/WarMap' import { CombatLossesPanel } from '@/components/CombatLossesPanel' -import { EventTimelinePanel } from '@/components/EventTimelinePanel' +import { IranBaseStatusPanel } from '@/components/IranBaseStatusPanel' import { BaseStatusPanel } from '@/components/BaseStatusPanel' import { PowerChart } from '@/components/PowerChart' import { InvestmentTrendChart } from '@/components/InvestmentTrendChart' @@ -71,7 +71,10 @@ export function Dashboard() { civilianTotal={situation.civilianCasualtiesTotal} className="min-w-0 flex-1 py-1" /> - +
diff --git a/src/store/situationStore.ts b/src/store/situationStore.ts index 902dcc4..870c09f 100644 --- a/src/store/situationStore.ts +++ b/src/store/situationStore.ts @@ -49,7 +49,7 @@ export function fetchAndSetSituation(): Promise { let disconnectWs: (() => void) | null = null let pollInterval: ReturnType | null = null -const POLL_INTERVAL_MS = 5000 +const POLL_INTERVAL_MS = 3000 function pollSituation() { fetchSituation() diff --git a/src/utils/tickerText.ts b/src/utils/tickerText.ts new file mode 100644 index 0000000..f1b3ac8 --- /dev/null +++ b/src/utils/tickerText.ts @@ -0,0 +1,35 @@ +/** + * 滚动情报文本处理:转为简体中文,过滤非中文内容 + */ +import { Converter } from 'opencc-js/t2cn' + +const t2s = Converter({ from: 'twp', to: 'cn' }) + +/** 简体中文字符范围 */ +const ZH_REGEX = /[\u4e00-\u9fff]/g + +/** 文本中中文占比是否达标(至少30%) */ +export function isMostlyChinese(text: string): boolean { + if (!text?.trim()) return false + const zh = text.match(ZH_REGEX) + const zhCount = zh ? zh.length : 0 + return zhCount / text.length >= 0.3 +} + +/** 繁体转简体 */ +export function toSimplifiedChinese(text: string): string { + if (!text?.trim()) return text + try { + return t2s(text) + } catch { + return text + } +} + +/** 处理滚动情报项:转为简体,非中文为主则过滤 */ +export function processTickerText(text: string): string | null { + const t = (text || '').trim() + if (!t) return null + if (!isMostlyChinese(t)) return null + return toSimplifiedChinese(t) +} diff --git a/tailwind.config.js b/tailwind.config.js index 2fb8af0..78d9842 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -11,9 +11,14 @@ export default { '0%': { transform: 'translateY(0)' }, '100%': { transform: 'translateY(-50%)' }, }, + ticker: { + from: { transform: 'translateX(0)' }, + to: { transform: 'translateX(-50%)' }, + }, }, animation: { 'vert-marquee': 'vert-marquee 25s linear infinite', + ticker: 'ticker var(--ticker-duration, 40s) linear infinite', }, fontFamily: { orbitron: ['Orbitron', 'sans-serif'],