diff --git a/crawler/__pycache__/extractor_rules.cpython-39.pyc b/crawler/__pycache__/extractor_rules.cpython-39.pyc index 52ddb93..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-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 b9f596a..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,8 +75,11 @@ 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)") @@ -87,7 +118,7 @@ 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} @@ -98,7 +129,7 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A if base_attacked: updates: list = [] # 常见美军基地关键词 -> name_keywords(用于 db_merge 的 LIKE 匹配) - bases_us = [ + bases_all = [ ("阿萨德|阿因|asad|assad|ain", "us"), ("巴格达|baghdad", "us"), ("乌代德|udeid|卡塔尔|qatar", "us"), @@ -113,8 +144,19 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A ("赛利耶|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_us: + 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: diff --git a/crawler/realtime_conflict_service.py b/crawler/realtime_conflict_service.py index 93f90f0..76b87c4 100644 --- a/crawler/realtime_conflict_service.py +++ b/crawler/realtime_conflict_service.py @@ -333,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: @@ -383,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/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-shm b/server/data.db-shm new file mode 100644 index 0000000..e9d50fa Binary files /dev/null and b/server/data.db-shm differ diff --git a/server/data.db-wal b/server/data.db-wal new file mode 100644 index 0000000..3c34608 Binary files /dev/null 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 ( +