From 4a8fff5a00a274e5a0dfc666c550892278fca746 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 2 Mar 2026 01:00:04 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E4=BC=98=E5=8C=96=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- =1.11.0 | 0 crawler/README.md | 95 ++++++ crawler/__pycache__/config.cpython-39.pyc | Bin 0 -> 1033 bytes crawler/__pycache__/db_writer.cpython-39.pyc | Bin 0 -> 3274 bytes crawler/__pycache__/parser.cpython-39.pyc | Bin 0 -> 1641 bytes .../realtime_conflict_service.cpython-39.pyc | Bin 0 -> 8558 bytes .../translate_utils.cpython-39.pyc | Bin 0 -> 1023 bytes crawler/config.py | 33 ++ crawler/db_writer.py | 110 +++++++ crawler/main.py | 57 ++++ crawler/parser.py | 52 ++++ crawler/realtime_conflict_service.py | 286 ++++++++++++++++++ crawler/requirements.txt | 6 + crawler/scrapers/__init__.py | 1 + .../__pycache__/__init__.cpython-311.pyc | Bin 0 -> 162 bytes .../__pycache__/__init__.cpython-39.pyc | Bin 0 -> 144 bytes .../__pycache__/rss_scraper.cpython-311.pyc | Bin 0 -> 3322 bytes .../__pycache__/rss_scraper.cpython-39.pyc | Bin 0 -> 2029 bytes crawler/scrapers/rss_scraper.py | 77 +++++ crawler/translate_utils.py | 28 ++ docs/BACKEND_MODULES.md | 91 ++++++ src/components/EventTimelinePanel.tsx | 89 ++++++ src/components/RecentUpdatesPanel.tsx | 71 +++++ src/components/TimelinePanel.tsx | 142 +++++++++ src/hooks/useReplaySituation.ts | 143 +++++++++ src/store/playbackStore.ts | 80 +++++ 26 files changed, 1361 insertions(+) create mode 100644 =1.11.0 create mode 100644 crawler/README.md create mode 100644 crawler/__pycache__/config.cpython-39.pyc create mode 100644 crawler/__pycache__/db_writer.cpython-39.pyc create mode 100644 crawler/__pycache__/parser.cpython-39.pyc create mode 100644 crawler/__pycache__/realtime_conflict_service.cpython-39.pyc create mode 100644 crawler/__pycache__/translate_utils.cpython-39.pyc create mode 100644 crawler/config.py create mode 100644 crawler/db_writer.py create mode 100644 crawler/main.py create mode 100644 crawler/parser.py create mode 100644 crawler/realtime_conflict_service.py create mode 100644 crawler/requirements.txt create mode 100644 crawler/scrapers/__init__.py create mode 100644 crawler/scrapers/__pycache__/__init__.cpython-311.pyc create mode 100644 crawler/scrapers/__pycache__/__init__.cpython-39.pyc create mode 100644 crawler/scrapers/__pycache__/rss_scraper.cpython-311.pyc create mode 100644 crawler/scrapers/__pycache__/rss_scraper.cpython-39.pyc create mode 100644 crawler/scrapers/rss_scraper.py create mode 100644 crawler/translate_utils.py create mode 100644 docs/BACKEND_MODULES.md create mode 100644 src/components/EventTimelinePanel.tsx create mode 100644 src/components/RecentUpdatesPanel.tsx create mode 100644 src/components/TimelinePanel.tsx create mode 100644 src/hooks/useReplaySituation.ts create mode 100644 src/store/playbackStore.ts diff --git a/=1.11.0 b/=1.11.0 new file mode 100644 index 0000000..e69de29 diff --git a/crawler/README.md b/crawler/README.md new file mode 100644 index 0000000..d392455 --- /dev/null +++ b/crawler/README.md @@ -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` 最新优先 | diff --git a/crawler/__pycache__/config.cpython-39.pyc b/crawler/__pycache__/config.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b48687ba1c5cfa3bccc00d54ab66ebac582dbfcb GIT binary patch literal 1033 zcma)4&ubhv6rR~x&y4N0;}m*GbMbW>*l`J@5JJc%-qN(eHoHzu=Q7Bn=Uv&FnMKm< z#&b0(?jb$4l$78NNg!!{Kzng13H0C4wf5Ti7jnwe`UmYH5P_cF)BB#jPkJI#Qxg`B zC)p$RPua5mHNk&}i@`OVvWJFRp+#+4;&y1`>hMzHgpO^21LZAyXKY}tyX$V~fxYhU zjMFkqfJev9Thu*oZP|lTIC-4$sE<(vrjFxrIx#41+2M5CqLUBp@C>dMTxV#hWmTv0 z3X&geKR^8S_qTWN9lU<|7GrCnYU}b6Q625gUyqjR%U^3(g6M%LJW3R)(N*o&mu^Pa z>#audX-BE<hi_8xi9qejb?r2R&;Y=xzYTtek*ivx9S#EgO&(i1E8`d zAXgw{EzXkQ*bh|JU4XtUF8m+5x*9XY)$)xX#dOfm1gC+JvUVro#g`ugP8g*eA|O(o z7M=>~_xm-%*GT~&$Vd>LYhVH4e8e$#!tr6~5rZKZRa`o$Ff!9k#S%EWt9ecqgA7hM zFd3|v8=1Q5xvK?b-YL@!^ku>~H@Px=acJJalqN3N*9YCkY4^ zBhvh@gEv3z|8W1*z5lwHJQ6r-6pr@yc39DH(^{L9*e_uJ+PA=0HT~#Y6}OV#IN{{cQN^Ug&nR zTy@yd*4AKu@E{!Pkc7zUS?xs;&EhE1rA%tCi+_)^RZ+rYwI4-m$bmUefXsLgm2`=K zRO!l6bMc$TjpeAhxVWr6NPA3Vsdn0+w1cXJ(Iv*rDGp1OFmIyAo2^##RiiQA(*C!N z?^hO^^R1_rIA@^7y!$-3jqQ;E#is`zaMojKJGkAd2eBZ14kC!Nbd9xZ-A(OZNiv#q ZxN4-z88l_vw=2$!{h2fEeB$^M?*VxCS-b!M literal 0 HcmV?d00001 diff --git a/crawler/__pycache__/db_writer.cpython-39.pyc b/crawler/__pycache__/db_writer.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f0ac9bc00e47f0545e8b6cd8ebfa20a24d0ab9c1 GIT binary patch literal 3274 zcmai0-ESOM6`${&UGLhC<2XqR(hylQxU0)p+mNJI?Hm zbLVb8+*zq=(ozX35TaE}lFExHY9A;OAPN;IJn}cpBkiX46E8)gd8qiEne|5<3bQ+R z&YXMhoqK=hoZq=lp^%es9XRtVZzC^BAJNOsi+aC@JNz3OCRHUSGsTyya`IHFO7c{z zDxRvZHMOeN)T_EocA9TAGt~@ay=pR}nsw#n98*~4hQdsiy{1(M+cAV0q{Y zuIA=h0XBnd2=9aJJ{s4zL?3=bY~+T?_Oj7ydUbfmtozs)M(lA%+`R`Sca-hNEC<*@ z==L$CEZO(Bk6|0VSKjKq{Ek)r{+uUVtM}(We$sva2ot_CgFmgCG5kJnDmRGzl=ILF)-Ke*C#TO!sS-{p9C zE#dm;@zB%62kNmNh=$8;Emr;DQfxF`=Czx#*6@}Zv03LHY#bk-)9OL16&u_YZQiQO zw2J;kvWoiz?r;oEB&`ASt}K%zcO{@MVaG~o^_=uETkeoV3by z>89RMnIhoGNL^Ey+J$4+)Gl1gw3n5G(tdKSUb-tsRkd}gwyL+q;*sxe3}Y%RBhzrg zhVL!JM%ZwUJ^bz1a9j0&xpB7PUS{4B9A#^qf`;#><^Y;l#hw#f(q@!&X07R*cWWNI zA8-BLj>_D^8{k!#NVoQ-arqS(M$)pxM<^zRvW$&%C_Xic)Fe9Zj*Xu?GTuBg&MM1cFwHRIy!I@@K$C=du=wA z^S8a9{<=T-!&fkD{ry|LzrGHGZ2(u7XG`Vce8oC7Z_PecIyDb+DOFDW7f$imq_xEq zlh!7%lU5&f2z`WY>+HodCZ*b!vXL zSdMe$;@RT-Yy~o>#Nd0#q96j{__cb&X(12dZ1Hm4T_MhoO&<9Ef>S?lXSd^lPteG% zEa5i2ev>9rM2JB2*zUM1crCDs=$DXhz%)FBM#`D8iP$q`Ex8pu6?yb?O(QMoIhCe+ zIDU&JzK&i*2WUdbYf4vI13`4~@y`rhrQLe$rkj=cI0#P?=D zo|PhbZJ1)Byd{ATzzX)>b^v^m8retjxb=e<4ke}Q(Bz?hCPG3LL%UVRAaCX?nf3GZ zP-oIQdi3b?$$Wjzc;i;@mv0l#ynYKjk@O@9yFt#R;}TIo&=zUok*ll51Jl#Tr>9FP z1xp)$hux0MOOlSrHOah`vyHTVeOY>L5JA{`VkHP;ZN(7{)WFMLC_+xP7c76lM_qp~ z&d>qNt&3P)az&i;LS(OST6Gk0)EAxva$%7NO;nM^faCNQXE;uH4lHtK9lz}sIS)90 zkXA*Zm-LZG%5dDmp7b6|`|Cl-pGmkYZuycc(%jIcxNZBb>va@KH39o(ES9 zA_Isya`-9o7#L$1Zzg0#9>F_l(f65=)$pVqO))=L6n+vj*vd9p+hcL2R%1cER^yMM zk5g^uk5EIqO)D%8Osz$4DFKT=KF&}GWJl%mOTAmQLxTL=Pw2MihxKqA(d zM*!m#oGfO?wi7N$Pa2Oj1J?H4M`;b=G#c70{f3o1(qB_Y3ZwdP9s&JXN}aUv{_o%U Iua0E@1I~*=bpQYW literal 0 HcmV?d00001 diff --git a/crawler/__pycache__/parser.cpython-39.pyc b/crawler/__pycache__/parser.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d6e3d645752717c313fb7d84545a07f9d04b6187 GIT binary patch literal 1641 zcmZ8h&2Jk;6rcU{+Ho9{qCog8Uk4Y2uN)%OC=F7i)Ku6#uoAL1I}>M>wb#5|CpB^o z&_V-JxFAu4B9Vp`2~cUdppcY*WUj<+@&|C?#BX+!Ds)%xYi8ced++yVyz%h@!5Jb>PM2MLHGi@&kd}&3=ilB-qjWAl5c3beE*Oul=&{~m>PXX3oH`}$d z-DJ-9hyp=96ptNk{xZ71^+~B6ghAp_|NK%f2)Sz|6h&RvUXI8YamA5#oKUbTr0pe% z=dVh0IUpgerO=BLX{-dvKWeu}Nba+}R6W|gH@ta}s-rKzfr<$7v4`MskRS`Ikxbib z3AY&cXiWrR=yiONMm5~}b@bg2sXDs;`RJ>=a5fV!Ty@7fq7@|p5|28rb6N%0iQ%ip zQlFy-n<#3w)8-#XKYm&cJM--=@Y8wMI2a4YU8?dg;wH!{8U|}Yr&YaLpRM}jtpnp~*CVWWyW5v`6A1e&W%$Ku&uc}^Nr_&@ zSVhkcc^0~_ft`Lzf1Jt;j0DLf+I5{uT&ri64(kP#Kssl@x}XwCEfdx|+^jQhAvL>3 zsy3u{OmQOxHRZr6RvL)R&&@WPvy1Z|G-OGsm*%f5zW?Fn`C22JTBtSV7f+@ZFm-vh zu`pNBvhY<|(%O)^C1_dnR2(e#Ra)^R7}E-x`9Gr9FkA4S`qarEyP(&RhK<3 z#1$B4F~!x^ceM=z(ttD*ZJ-abLLl|jLLuUrfyS*>oi1{_&Y{ zJBR!GqwRfCrOd9!Uk`9gX$*FTtm>@{Ugbd!7<%hDrKf#nWk9{52A)?ebxpmV1TPkA z0#8m~0TioTnCe&4F%^W9AO}QmV!Tq8xn`3`ezPfcyd`$h@8T+u!q0W)PB3+y`pJ&; z3MS~hVy`Op8kjWjEJ-sA@CczSQJBV?c1Lco(IMa@JFBI>plVWvERa8aY(HsM$ Keq+ih7yko0=B-Zv literal 0 HcmV?d00001 diff --git a/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc b/crawler/__pycache__/realtime_conflict_service.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c22b35ac1350eff50bca702dfbce1db67e08129b GIT binary patch literal 8558 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 C!K$DD literal 0 HcmV?d00001 diff --git a/crawler/__pycache__/translate_utils.cpython-39.pyc b/crawler/__pycache__/translate_utils.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a6fb40b3fb619829ecf06e1ebd908241e38bacb0 GIT binary patch literal 1023 zcmY*X-%Auh9G{t;yWI&$>$XJiaz zQo%Vm;^4{F!RCu!+aG^_f7)2sXncFwc)Ym3v+`?uc7JyVmJR`gav2;M*UQrOT+7Lj zh)O;qqom+65N-i0$k8D1O~5+}uRjbSM50?lT*Yf>9=e=H;HD4D~09yjwQJnNZau*lq*q}Y5Qi$^QBWUkIvKKFyDZHa5ttnjYTKS|6_(i zK_Oyh>M#Lj+JG0dhWvDhfDx?zUgb3`Nr+>%s2B)AVTKn-kE&~`E=Cl-dY);FI<%Ci z5sboEZKf}h)ntg*a7_y}F%@bq3DHeJQzZ4u97gI1Q4-yPS|5s?U<8+;oyMn!&80`p zdH)k$JWfymdMy|fiy3WvCOT|p*D8^+h|JfSUt7kpMii#8>rV7 zKL*SQ%(`#~M9~AQ86(n7lX(Tx6e-A|^-38GBa}r&x2n*)p6A4!$2(43uly$B9*m)q T#{!2Bt8KAsC^m4Lme&6QmGm8B literal 0 HcmV?d00001 diff --git a/crawler/config.py b/crawler/config.py new file mode 100644 index 0000000..db0a7af --- /dev/null +++ b/crawler/config.py @@ -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", +] diff --git a/crawler/db_writer.py b/crawler/db_writer.py new file mode 100644 index 0000000..64bb6da --- /dev/null +++ b/crawler/db_writer.py @@ -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() diff --git a/crawler/main.py b/crawler/main.py new file mode 100644 index 0000000..3dfc6ab --- /dev/null +++ b/crawler/main.py @@ -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() diff --git a/crawler/parser.py b/crawler/parser.py new file mode 100644 index 0000000..f069872 --- /dev/null +++ b/crawler/parser.py @@ -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" diff --git a/crawler/realtime_conflict_service.py b/crawler/realtime_conflict_service.py new file mode 100644 index 0000000..de2b11c --- /dev/null +++ b/crawler/realtime_conflict_service.py @@ -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) diff --git a/crawler/requirements.txt b/crawler/requirements.txt new file mode 100644 index 0000000..0268df8 --- /dev/null +++ b/crawler/requirements.txt @@ -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 diff --git a/crawler/scrapers/__init__.py b/crawler/scrapers/__init__.py new file mode 100644 index 0000000..40a96af --- /dev/null +++ b/crawler/scrapers/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/crawler/scrapers/__pycache__/__init__.cpython-311.pyc b/crawler/scrapers/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff0a0336fe91b1c13b6528a2e20137402fce471f GIT binary patch literal 162 zcmZ3^%ge<81e_sDG9`fYV-N=h7@>^MY(U0zh7^Wi22Do4l?+8pK>lZt ePO4oIE6@~>J;nS$;sY}yBjX1K7*WIw6axU|7bQ^u literal 0 HcmV?d00001 diff --git a/crawler/scrapers/__pycache__/__init__.cpython-39.pyc b/crawler/scrapers/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1412ab9d3365f8e2cb9739db4f4e06ac751d2e6c GIT binary patch literal 144 zcmYe~<>g`k0?v>nnG!(yF^Gc<7=auIATDMB5-AM944RC7D;bJF!U*D*seWj2YEiL% zN@8ASYL0$RW?5!lx_)S|qkeKxVtGz#k$y3dQ2K4&tT=HLIF=F{vE9P5T3E5moOmbcY`(g8 zv@GQa2xtpj3^ry^qh=8sxR1b5>jL#h1Ee1Xn!kP?3j!q$U?HGL0sql(3m9%cI(rl; zN>;nvo1NL6ot>GTo#9tLpBq8*&VMQW&VkV1DbZ-$HuCH zqLL-4IBp({kR5OqzGr^}0G_}z=-P!GR$6#eq+$U*1g=Mv(`q?JWyqT@q%>y`QW*gW z%+Ht|y98Egcp7$HZ}Lj`GaP3ynnQ<>;@lPqgJRuSonB&w5CQ*RLHTp5=oWhgt>XN} z*7L4Y-L1C&DG$MhM@>7xvmEuJKa*WdNb-s(#4#NUhNuquxdy9!*51@igx=QRgN{FfVf zyYi9KjYjO%J<$hUK*JS}_s-PqsZUSlPZqH@txZ<~J%x$FM2$t> z@RrA~O_Bp3EDm3@c>uFKRs{R+p=FdqtyjL=*1cXT;A?N>7)UuZaJJh{N`d49AtB-4 zvJXM_qo#jzY|6ho$u!eSq>)xs_Igt$+{V9ioZ@So0BMaAIKj3%{lQJ!a<)BO_LsOi z_i#8~;-GTu3VkixO||b%TRF!b8P0uWI91yhNN}l+FVF)v=TbTv@<4yrZizC^6V(Q( z+TEI}Rp%0ubE|GE!f^g4D%M>)GIlW4y;~YaO0Z>>+r66eDD;_ax4oL=yvk0k06kUj zp1bp@NN>4`h8(cTraFjQb$}I~Rx45%R-FG&Bh{<=1n>PlUdAZ@p90R0t5A|6zKiQ_ znxaQfFGCVv56;hxo+Er}bcUB#7SlWtqQ}PGh+wmwh(FGXvJzVnc>&V3X;UNx@;7}* zrbEsqlRQ)ak03${mokc!PMKUnO5J#Y2cLW&c%%ZPr-+@508S!w6PqOo)730cP{bkA zFQ>^Gp+z#%2DwdC2#rxr8zF9=rU zAn=a}SyD|qpUH?R0i@d`MNG;jCyQc=jL}m#dJ5^Hyi`lm0~IQ^ZJDJqIZD?C=^6{! zghVY2)TXN5l-q!Wn%C0mDaf@XLI#TQi3E8USPsD_{|3APsV5ZPn5_mwpU>Z&-%D(40WyvD&8*b zEL1EPtyo*Z-n&<8sN2i2$EiO}{(026@=@vStL3+^!i@3pqV{36`=EZb)IC=29@9Rk2ExTj{qW|irNC%8 zFlq#jKkn3KD&2iX;5F#Q<@%`2R0Bi$ zulXEd_@4maDRTJQ|49Lm+u-Q|>bEiE?btYPRrvd%N#<+!X~y;~#sDJ$(-Dga>3A$g z`r%4<{xOkpAWeHbom!HXtpK))lxeR=@J=*Z!3nVgf469jHo3)gI#G{ZcssIUa)K0B ztf2WdvI>k9P%oUPl4&8E5T^;PV6^PZH2`q!7;hoJf&3NZ+OU2V^qR3>-$ETmqi>Dt&F;j9iz77z!(Pyh@p$1l#3LRr)&~o30Ztq4D4wYy81xxy*bDrFxT7#p P}1&h~WAR@Llg zhMK2U5J3$Hc}av_@WmH@hY$XOdQ>9pUkC`E)3fGgH+4Cu>QtTc)%i}gun-gY{;Gb- z(+EZsp8XsU-iM~Xg^rViaLQRh6AGhKupvv>&`BIR*Sm=edbjX~e&P>Wa zaC5)Nl*$Ja8!E9aWUeP2YJDm6SeAW?XC8it0^axmH1!2^oGz2wAI+#Hh8W7pt}|nX zNq2fmQ^Ppn>>CDtJDS~ba{2>ZC5%wgBQDXd_6BV72?d#wTV%_<08P_9ANTH4h|Yyb zlJ;o4R^)0!@N|?(C3w;rk1)FbWHdq%r(^^UfdWjxeXRP}+x;`0+ z6)7ue>j($M1G;C?QpsXJsymZjo>2<$Cu< z?{c>YW z=g>yvJ!m!BCtzZTJfg`S<{UF*-{}FqfRl4|8eIJR%S*Sp1dN7%bJ6ZocT}Qaa3Ze?zErQ%rU$ z$rbKZHBSLU#1Zg&9dvzYPd-Gs0j+lQCSV9?k%G*~dhO^g0XNAW1GgO*9nSVCCHE*~ zth)^3?QrH!PipUuZ(QzhHwPYb?|WzFt&;KtPgO?y+ulPff(;j2mt&s^L1o= z?(<;R8BKXG3u-y6<@@zKn$KY^)UgS7=`5=CajpLi^sxyZ5Z*A+1M;&oYv_h)>{9jn z)`kgyPfg^Li3e=fG)=<*=s#hF?SX`G=mq1$nG3pwR`V_x_EA7T4s^A9{PCB*2dP^Re0h(#{d^8DVtB{5HGTG@v6iGJ;3*v#z>NL zm)C(s)8jXK=RTBKd2TsV8*5c2dH0?37vSL+rPvw^rPB?OaUe|MWoTeMoogTxCWsBx zcsR^}i487*OZiCWRcYNKFE=04(+8iyp8Ou%+wtWaFqdzlku#Qsjh++=C}=>r^-4}; z0Lz^v>#M52DKxMGx+N$OTnw^tp|LhrV^D$qd6jhW;CLVeugi|KUOn1q 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 diff --git a/crawler/translate_utils.py b/crawler/translate_utils.py new file mode 100644 index 0000000..63ddd77 --- /dev/null +++ b/crawler/translate_utils.py @@ -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 diff --git a/docs/BACKEND_MODULES.md b/docs/BACKEND_MODULES.md new file mode 100644 index 0000000..4b26780 --- /dev/null +++ b/docs/BACKEND_MODULES.md @@ -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 +``` diff --git a/src/components/EventTimelinePanel.tsx b/src/components/EventTimelinePanel.tsx new file mode 100644 index 0000000..26533a5 --- /dev/null +++ b/src/components/EventTimelinePanel.tsx @@ -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 = { + 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 ( +
+
+ + + 事件脉络 + + GDELT · Reuters · BBC · Al Jazeera · NYT +
+
+ {merged.length === 0 ? ( +

暂无事件

+ ) : ( +
    + {merged.map((item) => ( +
  • + + + +
    +

    {item.summary}

    + + {formatTime(item.timestamp)} + + {item.source === 'gdelt' ? 'GDELT' : CAT_LABELS[item.category ?? ''] ?? '新闻'} + + +
    +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/src/components/RecentUpdatesPanel.tsx b/src/components/RecentUpdatesPanel.tsx new file mode 100644 index 0000000..fa0cab1 --- /dev/null +++ b/src/components/RecentUpdatesPanel.tsx @@ -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 = { + deployment: '部署', + alert: '警报', + intel: '情报', + diplomatic: '外交', + other: '其他', +} +const SEV_COLORS: Record = { + 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 ( +
+
+ + + 近期动态 + +
+
+ {list.length === 0 ? ( +

暂无动态

+ ) : ( +
    + {list.map((u) => ( +
  • + + {u.severity === 'critical' && } + {CAT_LABELS[u.category] ?? u.category} + +
    +

    {u.summary}

    + {formatTime(u.timestamp)} +
    +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/src/components/TimelinePanel.tsx b/src/components/TimelinePanel.tsx new file mode 100644 index 0000000..8705ae1 --- /dev/null +++ b/src/components/TimelinePanel.tsx @@ -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 | 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) => { + const i = parseInt(e.target.value, 10) + setPlaybackTime(REPLAY_TICKS[i]) + } + + return ( +
+
+ + + {isReplayMode && ( + <> +
+ + + +
+ +
+ +
+ +
+ {formatTick(REPLAY_START)} + {formatTick(playbackTime)} + {formatTick(REPLAY_END)} +
+ + + + )} +
+
+ ) +} diff --git a/src/hooks/useReplaySituation.ts b/src/hooks/useReplaySituation.ts new file mode 100644 index 0000000..5ab9095 --- /dev/null +++ b/src/hooks/useReplaySituation.ts @@ -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]) +} diff --git a/src/store/playbackStore.ts b/src/store/playbackStore.ts new file mode 100644 index 0000000..6b49811 --- /dev/null +++ b/src/store/playbackStore.ts @@ -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((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 }), +}))