diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8fc4c53 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +.git +.env +.env.local +*.log +dist +server/data.db +.DS_Store +*.md +.cursor +.venv +__pycache__ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4fa2dc0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# 前端 + API 一体化镜像(使用 DaoCloud 国内镜像源) +FROM docker.m.daocloud.io/library/node:20-alpine AS frontend-builder +WORKDIR /app +ARG VITE_MAPBOX_ACCESS_TOKEN +ENV VITE_MAPBOX_ACCESS_TOKEN=${VITE_MAPBOX_ACCESS_TOKEN} +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM docker.m.daocloud.io/library/node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY --from=frontend-builder /app/dist ./dist +COPY server ./server + +ENV NODE_ENV=production +ENV API_PORT=3001 +ENV DB_PATH=/data/data.db +EXPOSE 3001 + +COPY docker-entrypoint.sh ./ +RUN chmod +x docker-entrypoint.sh +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/Dockerfile.crawler b/Dockerfile.crawler new file mode 100644 index 0000000..c975465 --- /dev/null +++ b/Dockerfile.crawler @@ -0,0 +1,17 @@ +# Python 爬虫服务(使用 DaoCloud 国内镜像源 + 清华 PyPI 源) +FROM docker.m.daocloud.io/library/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 +COPY crawler ./ + +ENV DB_PATH=/data/data.db +ENV API_BASE=http://api:3001 +ENV CLEANER_AI_DISABLED=1 +ENV GDELT_DISABLED=1 +ENV RSS_INTERVAL_SEC=60 + +EXPOSE 8000 + +CMD ["uvicorn", "realtime_conflict_service:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 0c3c129..6f49cbd 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,25 @@ npm run dev npm run build ``` +## Docker 部署 + +```bash +# 构建并启动(需 .env 中配置 VITE_MAPBOX_ACCESS_TOKEN 以启用地图) +docker compose up -d + +# 访问前端:http://localhost:3001 +# 数据库与爬虫共享 volume,首次启动自动 seed +``` + +**拉取镜像超时?** 在 Docker Desktop 配置镜像加速,见 [docs/DOCKER_MIRROR.md](docs/DOCKER_MIRROR.md) + +环境变量(可选,在 .env 或 docker-compose.yml 中配置): + +- `VITE_MAPBOX_ACCESS_TOKEN`:Mapbox 令牌,构建时注入 +- `DB_PATH`:数据库路径(默认 /data/data.db) +- `CLEANER_AI_DISABLED=1`:爬虫默认禁用 Ollama +- `GDELT_DISABLED=1`:爬虫默认禁用 GDELT(国内易超时) + ## Project Structure ``` diff --git a/crawler/__pycache__/config.cpython-39.pyc b/crawler/__pycache__/config.cpython-39.pyc index 88f7772..cad6fbd 100644 Binary files a/crawler/__pycache__/config.cpython-39.pyc and b/crawler/__pycache__/config.cpython-39.pyc differ diff --git a/crawler/__pycache__/db_merge.cpython-39.pyc b/crawler/__pycache__/db_merge.cpython-39.pyc index f0a99d7..2a24457 100644 Binary files a/crawler/__pycache__/db_merge.cpython-39.pyc and b/crawler/__pycache__/db_merge.cpython-39.pyc differ diff --git a/crawler/__pycache__/extractor_ai.cpython-39.pyc b/crawler/__pycache__/extractor_ai.cpython-39.pyc index b2859a7..e2b91e9 100644 Binary files a/crawler/__pycache__/extractor_ai.cpython-39.pyc and b/crawler/__pycache__/extractor_ai.cpython-39.pyc differ diff --git a/crawler/__pycache__/extractor_rules.cpython-39.pyc b/crawler/__pycache__/extractor_rules.cpython-39.pyc new file mode 100644 index 0000000..b3358bb Binary files /dev/null and b/crawler/__pycache__/extractor_rules.cpython-39.pyc differ diff --git a/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc b/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc index 6901c52..716af21 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/config.py b/crawler/config.py index eb117d6..79ebb29 100644 --- a/crawler/config.py +++ b/crawler/config.py @@ -39,11 +39,37 @@ RSS_FEEDS = [ "https://www.aljazeera.com/xml/rss/middleeast.xml", ] -# 关键词过滤:至少匹配一个才会入库 +# 关键词过滤:至少匹配一个才会入库(与地图区域对应:伊拉克/叙利亚/海湾/红海/地中海等) KEYWORDS = [ - "iran", "iranian", "tehran", "以色列", "israel", - "usa", "us ", "american", "美军", "美国", - "middle east", "中东", "persian gulf", "波斯湾", + # 伊朗 + "iran", "iranian", "tehran", "德黑兰", "bushehr", "布什尔", "abbas", "阿巴斯", + # 以色列 / 巴勒斯坦 + "israel", "以色列", "hamas", "gaza", "加沙", "hezbollah", "真主党", + # 美国 + "usa", "us ", "american", "美军", "美国", "pentagon", + # 区域(地图覆盖) + "middle east", "中东", "persian gulf", "波斯湾", "gulf of oman", "阿曼湾", + "arabian sea", "阿拉伯海", "red sea", "红海", "mediterranean", "地中海", + "strait of hormuz", "霍尔木兹", + # 伊拉克 / 叙利亚 + "iraq", "伊拉克", "baghdad", "巴格达", "erbil", "埃尔比勒", "basra", "巴士拉", + "syria", "叙利亚", "damascus", "大马士革", "deir", "代尔祖尔", + # 海湾国家 + "saudi", "沙特", "riyadh", "利雅得", "qatar", "卡塔尔", "doha", "多哈", + "uae", "emirates", "阿联酋", "dubai", "迪拜", "abu dhabi", + "bahrain", "巴林", "kuwait", "科威特", "oman", "阿曼", "yemen", "也门", + # 约旦 / 土耳其 / 埃及 / 吉布提 / 黎巴嫩 + "jordan", "约旦", "amman", "安曼", + "lebanon", "黎巴嫩", + "turkey", "土耳其", "incirlik", "因吉尔利克", + "egypt", "埃及", "cairo", "开罗", "sinai", "西奈", + "djibouti", "吉布提", + # 军事 / 基地 + "al-asad", "al asad", "阿萨德", "al udeid", "乌代德", "incirlik", "strike", "attack", "military", "missile", "核", "nuclear", - "carrier", "航母", "houthi", "胡塞", "hamas", + "carrier", "航母", "drone", "uav", "无人机", "retaliation", "报复", + "base", "基地", "troops", "troop", "soldier", "personnel", + # 胡塞 / 武装 / 军力 + "houthi", "胡塞", "houthis", + "idf", "irgc", "革命卫队", "qassem soleimani", "苏莱曼尼", ] diff --git a/crawler/db_merge.py b/crawler/db_merge.py index 11f6b14..c8464c3 100644 --- a/crawler/db_merge.py +++ b/crawler/db_merge.py @@ -67,7 +67,7 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool: ) if conn.total_changes > 0: updated = True - # combat_losses:增量叠加到当前值 + # combat_losses:增量叠加到当前值,无行则先插入初始行 if "combat_losses_delta" in extracted: for side, delta in extracted["combat_losses_delta"].items(): if side not in ("us", "iran"): @@ -77,13 +77,14 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool: "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], - } + cur = {"personnel_killed": 0, "personnel_wounded": 0, "civilian_killed": 0, "civilian_wounded": 0, + "bases_destroyed": 0, "bases_damaged": 0, "aircraft": 0, "warships": 0, "armor": 0, "vehicles": 0} + if row: + 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)) @@ -95,11 +96,18 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool: 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 row: + 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), + ) + else: + conn.execute( + """INSERT OR REPLACE INTO combat_losses (side, personnel_killed, personnel_wounded, civilian_killed, civilian_wounded, + bases_destroyed, bases_damaged, aircraft, warships, armor, vehicles, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (side, pk, pw, ck, cw, bd, bm, ac, ws, ar, vh, ts), + ) if conn.total_changes > 0: updated = True except Exception: @@ -115,6 +123,30 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool: w = extracted["wall_street"] conn.execute("INSERT INTO wall_street_trend (time, value) VALUES (?, ?)", (w["time"], w["value"])) updated = True + # key_location:更新受袭基地 status/damage_level + if "key_location_updates" in extracted: + try: + for u in extracted["key_location_updates"]: + kw = (u.get("name_keywords") or "").replace("|", " ").split() + side = u.get("side") + status = u.get("status", "attacked")[:20] + dmg = u.get("damage_level", 2) + if not kw or side not in ("us", "iran"): + continue + conditions = " OR ".join( + "(LOWER(name) LIKE ? OR name LIKE ?)" for _ in kw + ) + params = [status, dmg, side] + for k in kw: + params.extend([f"%{k}%", f"%{k}%"]) + cur = conn.execute( + f"UPDATE key_location SET status=?, damage_level=? WHERE side=? AND ({conditions})", + params, + ) + if cur.rowcount > 0: + updated = True + except Exception: + pass 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() diff --git a/crawler/extractor_ai.py b/crawler/extractor_ai.py index 146cc4d..cd38e80 100644 --- a/crawler/extractor_ai.py +++ b/crawler/extractor_ai.py @@ -27,11 +27,16 @@ def _call_ollama_extract(text: str, timeout: int = 10) -> Optional[Dict[str, Any - summary: 1-2句中文事实,≤80字 - category: deployment|alert|intel|diplomatic|other - severity: low|medium|high|critical -- us_personnel_killed, iran_personnel_killed 等:仅当新闻明确提及具体数字时填写 +- 战损(仅当新闻明确提及数字时填写,格式 us_XXX / iran_XXX): + us_personnel_killed, iran_personnel_killed, us_personnel_wounded, iran_personnel_wounded, + us_civilian_killed, iran_civilian_killed, us_civilian_wounded, iran_civilian_wounded, + us_bases_destroyed, iran_bases_destroyed, us_bases_damaged, iran_bases_damaged, + us_aircraft, iran_aircraft, us_warships, iran_warships, us_armor, iran_armor, us_vehicles, iran_vehicles - retaliation_sentiment: 0-100,仅当新闻涉及伊朗报复情绪时 - wall_street_value: 0-100,仅当新闻涉及美股/市场反应时 +- key_location_updates: 当新闻提及具体基地/地点遭袭时,数组项 { "name_keywords": "asad|阿萨德|assad", "side": "us", "status": "attacked", "damage_level": 1-3 } -原文:{str(text)[:500]} +原文:{str(text)[:800]} 直接输出 JSON,不要解释:""" r = requests.post( @@ -97,4 +102,17 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A v = parsed["wall_street_value"] if isinstance(v, (int, float)) and 0 <= v <= 100: out["wall_street"] = {"time": ts, "value": int(v)} + # key_location_updates:受袭基地 + if "key_location_updates" in parsed and isinstance(parsed["key_location_updates"], list): + valid = [] + for u in parsed["key_location_updates"]: + if isinstance(u, dict) and u.get("name_keywords") and u.get("side") in ("us", "iran"): + valid.append({ + "name_keywords": str(u["name_keywords"]), + "side": u["side"], + "status": str(u.get("status", "attacked"))[:20], + "damage_level": min(3, max(1, int(u["damage_level"]))) if isinstance(u.get("damage_level"), (int, float)) else 2, + }) + if valid: + out["key_location_updates"] = valid return out diff --git a/crawler/extractor_rules.py b/crawler/extractor_rules.py index 36c8227..89a1ee4 100644 --- a/crawler/extractor_rules.py +++ b/crawler/extractor_rules.py @@ -24,18 +24,64 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A 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)") + + # 美军人员伤亡 + 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 - v = _first_int(t, r"(\d+)[\s\w]*(?:us|american)[\s\w]*(?:troop|soldier|killed|dead)") + v = _first_int(t, r"(\d+)[\s\w]*(?:us|american)[\s\w]*(?:troop|soldier|military)[\s\w]*(?: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)") + v = _first_int(t, r"(?:us|american)[\s\w]*(\d+)[\s\w]*(?:wounded|injured)") + if v is not None: + loss_us["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 - v = _first_int(t, r"(\d+)[\s\w]*(?:iranian|iran)[\s\w]*(?:troop|soldier|killed|dead)") + v = _first_int(t, r"(\d+)[\s\w]*(?:iranian|iran)[\s\w]*(?:troop|soldier|guard|killed|dead)") if v is not None: loss_ir["personnel_killed"] = v + v = _first_int(t, r"(?:iran|iranian)[\s\w]*(\d+)[\s\w]*(?:wounded|injured)") + 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)") + 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)") + if v is not None: + loss_us["bases_destroyed"] = v + 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"): + 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"): + loss_us["bases_damaged"] = 1 + + # 战机 / 舰船(根据上下文判断阵营) + v = _first_int(t, r"(\d+)[\s\w]*(?:aircraft|plane|jet|fighter|f-?16|f-?35|f-?18)[\s\w]*(?:down|destroyed|lost|shot)") + if v is not None: + if "us" in t or "american" in t or "u.s" in t: + loss_us["aircraft"] = v + elif "iran" in t: + loss_ir["aircraft"] = v + else: + loss_us["aircraft"] = v + v = _first_int(t, r"(\d+)[\s\w]*(?:ship|destroyer|warship|vessel)[\s\w]*(?:hit|damaged|sunk)") + if v is not None: + if "iran" in t: + loss_ir["warships"] = v + else: + loss_us["warships"] = v if loss_us: out.setdefault("combat_losses_delta", {})["us"] = loss_us diff --git a/crawler/realtime_conflict_service.py b/crawler/realtime_conflict_service.py index 6e278f5..ba2004a 100644 --- a/crawler/realtime_conflict_service.py +++ b/crawler/realtime_conflict_service.py @@ -14,13 +14,13 @@ from datetime import datetime from pathlib import Path from typing import List, Optional +import asyncio 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) +logging.getLogger("uvicorn").setLevel(logging.INFO) app = FastAPI(title="GDELT Conflict Service") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"]) @@ -263,8 +263,9 @@ def _extract_and_merge_panel_data(items: list) -> None: from extractor_ai import extract_from_news from datetime import timezone merged_any = False - # 只对前几条有足够文本的新闻做提取,避免 Ollama 调用过多 - for it in items[:5]: + # 规则模式可多处理几条(无 Ollama);AI 模式限制 5 条避免调用过多 + limit = 10 if os.environ.get("CLEANER_AI_DISABLED", "0") == "1" else 5 + for it in items[:limit]: text = (it.get("title", "") or "") + " " + (it.get("summary", "") or "") if len(text.strip()) < 20: continue @@ -292,12 +293,22 @@ def _extract_and_merge_panel_data(items: list) -> None: # ========================== -# 定时任务(RSS 更频繁,优先保证事件脉络实时) +# 定时任务(asyncio 后台任务,避免 APScheduler executor 关闭竞态) # ========================== -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() +_bg_task: Optional[asyncio.Task] = None + + +async def _periodic_fetch() -> None: + loop = asyncio.get_event_loop() + while True: + try: + await loop.run_in_executor(None, fetch_news) + await loop.run_in_executor(None, fetch_gdelt_events) + except asyncio.CancelledError: + break + except Exception as e: + print(f" [warn] 定时抓取: {e}") + await asyncio.sleep(min(RSS_INTERVAL_SEC, FETCH_INTERVAL_SEC)) # ========================== @@ -356,18 +367,23 @@ def _get_conflict_stats() -> dict: @app.on_event("startup") -def startup(): - # 新闻优先启动,确保事件脉络有数据 - fetch_news() - fetch_gdelt_events() +async def startup(): + global _bg_task + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, fetch_news) + await loop.run_in_executor(None, fetch_gdelt_events) + _bg_task = asyncio.create_task(_periodic_fetch()) @app.on_event("shutdown") -def shutdown(): - try: - scheduler.shutdown(wait=False) - except Exception: - pass +async def shutdown(): + global _bg_task + if _bg_task and not _bg_task.done(): + _bg_task.cancel() + try: + await _bg_task + except asyncio.CancelledError: + pass if __name__ == "__main__": diff --git a/crawler/requirements.txt b/crawler/requirements.txt index 0268df8..e1a5d6e 100644 --- a/crawler/requirements.txt +++ b/crawler/requirements.txt @@ -2,5 +2,4 @@ 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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bb3677c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + api: + build: + context: . + args: + - VITE_MAPBOX_ACCESS_TOKEN=${VITE_MAPBOX_ACCESS_TOKEN:-} + ports: + - "3001:3001" + environment: + - DB_PATH=/data/data.db + - API_PORT=3001 + volumes: + - app-data:/data + restart: unless-stopped + + crawler: + build: + context: . + dockerfile: Dockerfile.crawler + environment: + - DB_PATH=/data/data.db + - API_BASE=http://api:3001 + - CLEANER_AI_DISABLED=1 + - GDELT_DISABLED=1 + - RSS_INTERVAL_SEC=60 + volumes: + - app-data:/data + depends_on: + - api + restart: unless-stopped + +volumes: + app-data: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..7b05efd --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e +export DB_PATH="${DB_PATH:-/data/data.db}" +if [ ! -f "$DB_PATH" ]; then + echo "==> Seeding database..." + node server/seed.js +fi +exec node server/index.js diff --git a/docs/DOCKER_MIRROR.md b/docs/DOCKER_MIRROR.md new file mode 100644 index 0000000..e8e20dd --- /dev/null +++ b/docs/DOCKER_MIRROR.md @@ -0,0 +1,30 @@ +# Docker 拉取超时 / 配置镜像加速 + +国内环境从 Docker Hub 拉取镜像常超时,需在 Docker 中配置镜像加速。 + +## Docker Desktop(macOS / Windows) + +1. 打开 **Docker Desktop** +2. **Settings** → **Docker Engine** +3. 在 JSON 中增加 `registry-mirrors`(若已有其他配置,只需合并进该字段): + +```json +{ + "registry-mirrors": [ + "https://docker.m.daocloud.io", + "https://docker.1ms.run" + ] +} +``` + +4. 点击 **Apply & Restart** +5. 重新执行:`docker compose up -d --build` + +## 备选镜像源 + +可替换或补充到 `registry-mirrors` 中: + +- `https://docker.m.daocloud.io`(DaoCloud) +- `https://docker.1ms.run` +- `https://docker.rainbond.cc`(好雨科技) +- 阿里云 / 腾讯云等:在对应云控制台的「容器镜像服务」中获取个人专属加速地址 diff --git a/server/data.db-shm b/server/data.db-shm index 45a9882..db40834 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 f85fcc0..1bd86b3 100644 Binary files a/server/data.db-wal and b/server/data.db-wal differ diff --git a/server/db.js b/server/db.js index 63b924a..f7d77b5 100644 --- a/server/db.js +++ b/server/db.js @@ -1,7 +1,7 @@ const Database = require('better-sqlite3') const path = require('path') -const dbPath = path.join(__dirname, 'data.db') +const dbPath = process.env.DB_PATH || path.join(__dirname, 'data.db') const db = new Database(dbPath) // 启用外键 @@ -147,4 +147,19 @@ addUpdatedAt('force_asset') addUpdatedAt('key_location') addUpdatedAt('retaliation_current') +// 来访统计:visits 用于在看(近期活跃 IP),visitor_count 用于累积人次(每次接入 +1) +try { + db.exec(` + CREATE TABLE IF NOT EXISTS visits ( + ip TEXT PRIMARY KEY, + last_seen TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS visitor_count ( + id INTEGER PRIMARY KEY CHECK (id = 1), + total INTEGER NOT NULL DEFAULT 0 + ); + INSERT OR IGNORE INTO visitor_count (id, total) VALUES (1, 0); + `) +} catch (_) {} + module.exports = db diff --git a/server/index.js b/server/index.js index cd8638b..395d3d5 100644 --- a/server/index.js +++ b/server/index.js @@ -1,4 +1,6 @@ const http = require('http') +const path = require('path') +const fs = require('fs') const express = require('express') const cors = require('cors') const { WebSocketServer } = require('ws') @@ -8,6 +10,7 @@ const { getSituation } = require('./situationData') const app = express() const PORT = process.env.API_PORT || 3001 +app.set('trust proxy', 1) app.use(cors()) app.use(express.json()) app.use('/api', routes) @@ -17,6 +20,17 @@ app.post('/api/crawler/notify', (_, res) => { res.json({ ok: true }) }) +// 生产环境:提供前端静态文件 +const distPath = path.join(__dirname, '..', 'dist') +if (fs.existsSync(distPath)) { + app.use(express.static(distPath)) + app.get('*', (req, res, next) => { + if (!req.path.startsWith('/api') && req.path !== '/ws') { + res.sendFile(path.join(distPath, 'index.html')) + } else next() + }) +} + const server = http.createServer(app) const wss = new WebSocketServer({ server, path: '/ws' }) diff --git a/server/routes.js b/server/routes.js index 05c07a2..8119323 100644 --- a/server/routes.js +++ b/server/routes.js @@ -62,6 +62,46 @@ router.get('/situation', (req, res) => { } }) +// 来访统计:记录 IP,返回在看/累积 +function getClientIp(req) { + const forwarded = req.headers['x-forwarded-for'] + if (forwarded) return forwarded.split(',')[0].trim() + return req.ip || req.socket?.remoteAddress || 'unknown' +} + +router.post('/visit', (req, res) => { + try { + const ip = getClientIp(req) + db.prepare( + "INSERT OR REPLACE INTO visits (ip, last_seen) VALUES (?, datetime('now'))" + ).run(ip) + db.prepare( + 'INSERT INTO visitor_count (id, total) VALUES (1, 1) ON CONFLICT(id) DO UPDATE SET total = total + 1' + ).run() + const viewers = db.prepare( + "SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')" + ).get().n + const cumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0 + res.json({ viewers, cumulative }) + } catch (err) { + console.error(err) + res.status(500).json({ viewers: 0, cumulative: 0 }) + } +}) + +router.get('/stats', (req, res) => { + try { + const viewers = db.prepare( + "SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')" + ).get().n + const cumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0 + res.json({ viewers, cumulative }) + } catch (err) { + console.error(err) + res.status(500).json({ viewers: 0, cumulative: 0 }) + } +}) + router.get('/events', (req, res) => { try { const s = getSituation() diff --git a/src/components/HeaderPanel.tsx b/src/components/HeaderPanel.tsx index 67e491a..6cdfda6 100644 --- a/src/components/HeaderPanel.tsx +++ b/src/components/HeaderPanel.tsx @@ -1,10 +1,19 @@ import { useState, useEffect } from 'react' -import { Link } from 'react-router-dom' import { StatCard } from './StatCard' import { useSituationStore } from '@/store/situationStore' import { useReplaySituation } from '@/hooks/useReplaySituation' import { usePlaybackStore } from '@/store/playbackStore' -import { Wifi, WifiOff, Clock, Database } from 'lucide-react' +import { Wifi, WifiOff, Clock, Share2, Heart, Eye } from 'lucide-react' + +const STORAGE_LIKES = 'us-iran-dashboard-likes' + +function getStoredLikes(): number { + try { + return parseInt(localStorage.getItem(STORAGE_LIKES) ?? '0', 10) + } catch { + return 0 + } +} export function HeaderPanel() { const situation = useReplaySituation() @@ -12,12 +21,64 @@ export function HeaderPanel() { const isReplayMode = usePlaybackStore((s) => s.isReplayMode) const { usForces, iranForces } = situation const [now, setNow] = useState(() => new Date()) + const [likes, setLikes] = useState(getStoredLikes) + const [liked, setLiked] = useState(false) + const [viewers, setViewers] = useState(0) + const [cumulative, setCumulative] = useState(0) useEffect(() => { const timer = setInterval(() => setNow(new Date()), 1000) return () => clearInterval(timer) }, []) + const fetchStats = async () => { + try { + const res = await fetch('/api/visit', { method: 'POST' }) + const data = await res.json() + if (data.viewers != null) setViewers(data.viewers) + if (data.cumulative != null) setCumulative(data.cumulative) + } catch { + setViewers((v) => (v > 0 ? v : 0)) + setCumulative((c) => (c > 0 ? c : 0)) + } + } + + useEffect(() => { + fetchStats() + const t = setInterval(fetchStats, 30000) + return () => clearInterval(t) + }, []) + + const handleShare = async () => { + const url = window.location.href + const title = '美伊军事态势显示' + if (typeof navigator.share === 'function') { + try { + await navigator.share({ title, url }) + } catch (e) { + if ((e as Error).name !== 'AbortError') { + await copyToClipboard(url) + } + } + } else { + await copyToClipboard(url) + } + } + + const copyToClipboard = (text: string) => { + return navigator.clipboard?.writeText(text) ?? Promise.resolve() + } + + const handleLike = () => { + if (liked) return + setLiked(true) + const next = likes + 1 + setLikes(next) + try { + localStorage.setItem(STORAGE_LIKES, String(next)) + } catch {} + } + const formatDateTime = (d: Date) => d.toLocaleString('zh-CN', { year: 'numeric', @@ -59,14 +120,33 @@ export function HeaderPanel() { )} -
- +
+ + 在看 {viewers} + | + 累积 {cumulative} +
+ + {isConnected ? ( <> diff --git a/src/components/WarMap.tsx b/src/components/WarMap.tsx index 46fdbdb..3d0dc9a 100644 --- a/src/components/WarMap.tsx +++ b/src/components/WarMap.tsx @@ -286,6 +286,8 @@ export function WarMap() { const tick = (t: number) => { const elapsed = t - startRef.current + const zoom = map.getZoom() + const zoomScale = Math.max(0.5, zoom / 4.2) // 随镜头缩放:放大变大、缩小变小(4.2 为默认 zoom) try { // 光点从起点飞向目标的循环动画 const src = map.getSource('attack-dots') as { setData: (d: GeoJSON.FeatureCollection) => void } | undefined @@ -307,11 +309,11 @@ export function WarMap() { const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.003) map.setPaintProperty('points-damaged', 'circle-opacity', blink) } - // attacked: 红色脉冲 2s 循环, 扩散半径 0→40px, opacity 1→0 (map.md) + // attacked: 红色脉冲 2s 循环, 半径随 zoom 缩放 if (map.getLayer('points-attacked-pulse')) { const cycle = 2000 const phase = (elapsed % cycle) / cycle - const r = 40 * phase + const r = 40 * phase * zoomScale const opacity = 1 - phase map.setPaintProperty('points-attacked-pulse', 'circle-radius', r) map.setPaintProperty('points-attacked-pulse', 'circle-opacity', opacity) @@ -376,11 +378,11 @@ export function WarMap() { ) israelSrc.setData({ type: 'FeatureCollection', features }) } - // 伊朗被打击目标:蓝色脉冲 (2s 周期) + // 伊朗被打击目标:蓝色脉冲 (2s 周期), 半径随 zoom 缩放 if (map.getLayer('allied-strike-targets-pulse')) { const cycle = 2000 const phase = (elapsed % cycle) / cycle - const r = 35 * phase + const r = 35 * phase * zoomScale const opacity = Math.max(0, 1 - phase * 1.2) map.setPaintProperty('allied-strike-targets-pulse', 'circle-radius', r) map.setPaintProperty('allied-strike-targets-pulse', 'circle-opacity', opacity) @@ -390,11 +392,11 @@ export function WarMap() { const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.004) map.setPaintProperty('gdelt-events-orange', 'circle-opacity', blink) } - // GDELT 红色 (7–10):脉冲扩散 + // GDELT 红色 (7–10):脉冲扩散, 半径随 zoom 缩放 if (map.getLayer('gdelt-events-red-pulse')) { const cycle = 2200 const phase = (elapsed % cycle) / cycle - const r = 30 * phase + const r = 30 * phase * zoomScale 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)