From 92914e652282a049f98909676c293c277ccba45b Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 2 Mar 2026 23:21:07 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E7=9A=84=E9=A9=B1=E5=8A=A8=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crawler/__pycache__/db_merge.cpython-39.pyc | Bin 6983 -> 7201 bytes .../extractor_rules.cpython-39.pyc | Bin 5568 -> 7443 bytes crawler/db_merge.py | 17 +- crawler/extractor_ai.py | 8 +- crawler/extractor_dashscope.py | 11 +- crawler/extractor_rules.py | 101 +++++++-- crawler/requirements.txt | 1 + crawler/tests/__init__.py | 1 + .../tests/__pycache__/__init__.cpython-39.pyc | Bin 0 -> 141 bytes ...est_extraction.cpython-39-pytest-8.4.2.pyc | Bin 0 -> 16348 bytes crawler/tests/test_extraction.py | 198 ++++++++++++++++++ docs/DATA_FLOW.md | 62 ++++++ package.json | 1 + server/data.db-shm | Bin 32768 -> 32768 bytes server/data.db-wal | Bin 4124152 -> 4124152 bytes server/index.js | 8 +- server/routes.js | 13 +- server/stats.js | 13 ++ src/api/websocket.ts | 2 +- src/components/HeaderPanel.tsx | 28 +-- src/store/situationStore.ts | 7 +- src/store/statsStore.ts | 18 ++ 22 files changed, 427 insertions(+), 62 deletions(-) create mode 100644 crawler/tests/__init__.py create mode 100644 crawler/tests/__pycache__/__init__.cpython-39.pyc create mode 100644 crawler/tests/__pycache__/test_extraction.cpython-39-pytest-8.4.2.pyc create mode 100644 crawler/tests/test_extraction.py create mode 100644 docs/DATA_FLOW.md create mode 100644 server/stats.js create mode 100644 src/store/statsStore.ts diff --git a/crawler/__pycache__/db_merge.cpython-39.pyc b/crawler/__pycache__/db_merge.cpython-39.pyc index 603ecce7feb97ab59cb2aea52afca6c249e38700..7c24f51e8203708ac3357c1dbfc51e594674c535 100644 GIT binary patch delta 1234 zcmZWp-*4Mg6u$S`j+1O@N|&TbXxEMcBGWXDmuXT%TGqAQFE&v3f~CrGeXZ2Qj=gKA z*@~k)P~ZtE$Y>86MF|o}lO`b)o_OGu2LzgUybp*M_y>4HNN}#RO{jBpj*mZo_k8D^ zdz~N8k1k9Zxm*^(_0gAK+1syvJ9P(RZTvBQN6n=H<&x<+4-q>N|B-$l7h@w+#hEzD z%;R!=KXXR;eW2`rG5#qN$o+f!zeR{HSI{N2{kW$@$}mmqBRr7(AE-J!F+#n$Xl@_} zzYOHtkLVk;ae$(^VS%pD)e#vMN3iY#+?!9`|oB!p0sD+36(t#;`;;>17a@7fB)6VstWEh}5P;uOz|=K8t#%B59bR zSEI!Th+aFCqEqp|Q%jZ4FcQCaQJb7YkqmjiOL*7g<^1dTpV-Z>+2@6pbys&h(+F*^t#^ECgjT?FZNs&6c-8e74NRf=^db<*U|V3p9JnTm&~~AR zBRoVS#N^5SQ*vA0f}I6I4Ra+3ne9I$oSZ$!aVvp^JC9fa7(82d>>xBf*Kfq|{1+J7 zuecU#T4(E`IwxpA@Qib>v;+-&U)ty&6O}GrzjAGTaplHDj<8i&&awVX=7~zC-n81* z9iKJs1A(mxk@8WIrsyO;7i5ouUZC?xI6!(s2#{jbE7vuK@2z3G5zZDt9kLe$NiiKSumtKjJBHb+EwK{dY*x6pg-bp>b+Xfx;|`ZPHf0;hVrn^| z!6irupJs<~;-L&02)7$zs(fD?mhWN%j^6v%F%kzJpYV26`1;FxjT z)l#jl&WtW6tuEUH5ly0QlV_n}YKt=$@RV;b!v(%|WLjKFHs^wveDG@=R}PB>oWe4x z;w&!X<9Hh40{mBOR$s1kfKLh1qPLnRJyXUQAHfNyN{~hFMc<148VtULBx*a-ei) zM!mHGboEhn8DAM79K%;XL59+u9ck-0J3!rY!Awu1j{_YikDwQ#(Vlkr7;p4bxPY%c zP=c8a`MV_}67hO~WO)UED$ajOS#ovP8*kQZ?H-}H-8W=q6lOsZ_t$Tc| zL2$L*pdd2aFAZNN9zfzXQPXh=F$ojwVDd71GMSrNlJtUvS1rR|C%;BeVxOhH61be+ zTr4GPm}@tPC0ZSiVxPU4SrGo6%(eRqx$WWyg}t2Y=gsZw zHw)&PS==)v|A*#xXi_YmM;t9*_{X$S53DDpq+aR<;mRICf z>i+8TzotK6@nrqI-Hj{z1+&I(XCKWaAwBR!ED+*{o4yhel?1 zj2yR!i+PxO9mi|CEjlT^A*b&7M5^_A*>-CA1)32$%}UskF#0p~vn()&HdotKOjp?p zBb(x=*(o8XCN6O=V~)xV&i*tq?{jp}EFr(lQOoB*IdnLxb?BOySWAYc0>4T9LTE)g+^Ii5)b+>nKyNsnSm%IVH^^mi3f)f0jo&2fsR^;d5^Kx|Z<)e~Wz(K%Q7$!Hmla!82Bu89SKKYF1Wk+h3Nhw#+LHHou^+E3@USl+u`e zpL-OGtuOPqSr)yl%r;kMTV7(5iB!h9qRiHg@21jGDJPpPc;$$cWsq#*0kF}821vnx zc%K@)KFI`AKlH6c=Dlqkk`ppkik%0h*BL42JaIcGk6Be#PIg8FT;Tytq6k22yg4~_ z6UMl@EbPxka{MLk@fUU+a=xUBo4LFQGUD==p&T?Apn;svT;v6OrG&|sC9QnN_jp6T zx70{>aod^>2{Rf%T~ZiVhbF2R?*AP5(6 zn-}chbpqWl(0YM32=su9$Rba&h9_C%NuX%H+l?g~QpjGsiAN~rI{Y9**&KtLT_xGd z_qzo~u*w^YLLTF-W;*w50OG`3%ZOhV{x5LM?*FjxmvRc3^Bed``}?t`L!?H1wVPC$VyJ$&c$QCK9m)oEhyu+#Q<|! zl<^4<{e~z55D(p1+yQJrC7Ou*%!^dLufU@s5)f6ucQsY0FR$TUhBP5(@g`9FLA{y! zm(+MAoYCF9sjAt9_XzY&fgW=aIgB5tGJaxQrat^Fi2AnYF^QiP=sN;ECD2}uvZ^_W z_qj@P8Hk?7-{n-#0M+-nK9KqW-Y<}QGnmf34-C0?grR4JpL>^>E-G{f1nMfHEIwF- z_&I?#z#$5xw(>*7C=PLXmyn<5@-sqyfXlr?ZsGD#CJ$=fT@RS&ysOTT?&dPH=Ww?R zlkZV&u!2_PG@|uMgTX!t_nbq6m9%oFbg-jK9t;gur7MP|UW74@y`s@jK8SlK&`xOx z4WV3sOw(Y#l7?s%-jDlmd|a782)6Nk&@)aNgaH{aY~Ma9ayS!*Kd`~(=Be4hezYN@ zf;2z#x_x3yz_A;dCeYfaC!O)pF0-|z3%5n!Jy$49MJk>}XuI{Xw#<4fSg#)FGP{l) zoVpn7M72DAZu*DL_}N*-AbP_3Tjd+sy};xuUEkK09qsey3k&Bypw6ge4_}XX`dcsN zosryu=eqDgH{+7OeQnCQbcRBHIye2^Twx-T39$lCjLj8Zn;*OAQqZ$1ub+u-QR6tqIM?bcIdD5P_8dW*9nd!d00Rx6<8vQY9e>iPVUn|*r zuR&nT{d(r9L=P}hBax1go>(dkc&R1!=`;4FSE%#aq%(5C>rEu8d8}sUfZLw(EPPjj z|No|sxNPh=E)6gq>oL4L(YDa-ch^vK0o+F7U0FK7b3w zp?j&Kfn+9~N}3SXtM?fFM*6t*cy(koyVnspQwuNq>SZTCy)ZLFyY;>})-h?aepKCx znyeeu55fA6>dmmW)-=Jor-tFdnig0mYu3X0X-x#yf7U#*&7-UX%7tHy*(2u{Uc5q0 z{dm7|T#pma%KmfD625a5vc-vRBp};BZ6k1u*0we^xMkHaubHd2uAi}Ay+qS7a7>T} z>vy%QP=oc?+WSiV<_lBy+mrUI*Jwgd>xmb@Z)05p_&vebzPdGlPt~o4^?knnrmh?P z$y$HcM$5SCCn#(w^^ui9^N5i;#6A~hG~~|U(6J=JrnRmo>5)VKQoiuWzI70* b$YHH9gjS=wLC|Ef#oD=SH`-#IT(%}x+cs)!ZEZ`i1wkS>@y2AaUF)5V zv~t%i#L@^N+uqWGM%@*Z#s^7BZDSLnAo%2ii1<(neR11}qW^%1XYS07JG<=RoAZ6= zJNKTm3?Kb&AYz2WngpNhfzOLCX0Aq-|c1CgdYI7vObt=h!-YLu5A%<Fxgs=#0<9DGpNZ_7E_AN z%O#oM)}$bxXz2wXZ}XDru;3ET{^S@cG)Hm zuLP_xe#B#Z#nsn^eh55lJs^f_3f*9sDRq@RT9g_x8Gvln2kBp!uPr?Yqj}6Z)<0y} zMUT7a34t)x$WA7cU5heP$Ou%8+Ie#ydD2A%7wvY@9vAJk?~my*Fd8FI39F~wU1M|B zSq*-pPM)#LPS%~(U_kalEMzavLiTDbG;UQ?I#@@|NAZkQQD-Fb>}RMFV1aSz<-(*~ zIjmKJ<-j@VHAME2{qL$wt83)B1{#;<(LAbQy2{jgkZCMP4v>T7`9<77NUGsCA#a&7 z74Cir2B{pC@`qP`)m}j>rO*UIqx{=&UY;~p{tAyFzPIxtKeXjq^cJ2n%;`BlynLFU z-fS)|Ux?oJ-@-*|l=$uFG5%Zk_d&tleUCTKt?^JSpVdK}m*=8Z&5dh!>+72v8*Iv$ znI#6HIzJlgM>>BewiDv@*f7LDVi|~mxP|w``ykH5w?X_Mo`<*^-#y}3c7f&Q*B><( zmNw5^XQpxbc=5C`OC2`iH2mZ;bep9{u>{?AB+}qzIMKOcA1r@ zL0F<0em(hkiQXiraUrXirzp1`pKN!-O6?*tk zIyblhP{J{UQApMl4XN bool: if "key_location_updates" in extracted: try: for u in extracted["key_location_updates"]: - kw = (u.get("name_keywords") or "").replace("|", " ").split() + kw_raw = (u.get("name_keywords") or "").strip() + if not kw_raw: + continue + # 支持 "a|b|c" 或 "a b c" 分隔 + kw = [k.strip() for k in kw_raw.replace("|", " ").split() if k.strip()] side = u.get("side") - status = u.get("status", "attacked")[:20] + status = (u.get("status") or "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}%"]) + # 简化:name LIKE '%kw%' 对每个关键词 OR 连接,支持中英文 + conditions = " OR ".join("name LIKE ?" for _ in kw) + params = [status, dmg, side] + [f"%{k}%" for k in kw] cur = conn.execute( f"UPDATE key_location SET status=?, damage_level=? WHERE side=? AND ({conditions})", params, diff --git a/crawler/extractor_ai.py b/crawler/extractor_ai.py index cd38e80..e87d6bd 100644 --- a/crawler/extractor_ai.py +++ b/crawler/extractor_ai.py @@ -30,8 +30,10 @@ def _call_ollama_extract(text: str, timeout: int = 10) -> Optional[Dict[str, Any - 战损(仅当新闻明确提及数字时填写,格式 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 + us_bases_destroyed, iran_bases_destroyed, us_bases_damaged, iran_bases_damaged. + 重要:bases_* 仅指已确认损毁/受损的基地数量;"军事目标"/targets 等泛指不是基地,若报道只说"X个军事目标遭袭"而无具体基地名,不填写 bases_* + us_aircraft, iran_aircraft, us_warships, iran_warships, us_armor, iran_armor, us_vehicles, iran_vehicles, + us_drones, iran_drones, us_missiles, iran_missiles, us_helicopters, iran_helicopters, us_submarines, iran_submarines - retaliation_sentiment: 0-100,仅当新闻涉及伊朗报复情绪时 - wall_street_value: 0-100,仅当新闻涉及美股/市场反应时 - key_location_updates: 当新闻提及具体基地/地点遭袭时,数组项 { "name_keywords": "asad|阿萨德|assad", "side": "us", "status": "attacked", "damage_level": 1-3 } @@ -79,7 +81,7 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A # combat_losses 增量(仅数字字段) loss_us = {} loss_ir = {} - for k in ["personnel_killed", "personnel_wounded", "civilian_killed", "civilian_wounded", "bases_destroyed", "bases_damaged", "aircraft", "warships", "armor", "vehicles"]: + for k in ["personnel_killed", "personnel_wounded", "civilian_killed", "civilian_wounded", "bases_destroyed", "bases_damaged", "aircraft", "warships", "armor", "vehicles", "drones", "missiles", "helicopters", "submarines"]: uk = f"us_{k}" ik = f"iran_{k}" if uk in parsed and isinstance(parsed[uk], (int, float)): diff --git a/crawler/extractor_dashscope.py b/crawler/extractor_dashscope.py index 8d92bbf..ff4c81e 100644 --- a/crawler/extractor_dashscope.py +++ b/crawler/extractor_dashscope.py @@ -33,11 +33,13 @@ def _call_dashscope_extract(text: str, timeout: int = 15) -> Optional[Dict[str, - 战损(仅当新闻明确提及数字时填写): 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 + us_bases_destroyed, iran_bases_destroyed, us_bases_damaged, iran_bases_damaged. + 重要:bases_* 仅指已确认损毁/受损的基地数量;"军事目标"/"targets"等泛指不是基地,若报道只说"X个军事目标遭袭"而无具体基地名,不填写 bases_* + us_aircraft, iran_aircraft, us_warships, iran_warships, us_armor, iran_armor, us_vehicles, iran_vehicles, + us_drones, iran_drones, us_missiles, iran_missiles, us_helicopters, iran_helicopters, us_submarines, iran_submarines - retaliation_sentiment: 0-100,仅当新闻涉及伊朗报复/反击情绪时 - wall_street_value: 0-100,仅当新闻涉及美股/市场反应时 -- key_location_updates: 当新闻提及具体基地遭袭时,数组 [{{"name_keywords":"阿萨德|asad|assad","side":"us","status":"attacked","damage_level":1-3}}] +- key_location_updates: 当新闻提及具体基地/设施遭袭时必填,数组 [{{"name_keywords":"阿萨德|asad|assad|阿因","side":"us","status":"attacked","damage_level":1-3}}]。常用关键词:阿萨德|asad|巴格达|baghdad|乌代德|udeid|埃尔比勒|erbil|因吉尔利克|incirlik|德黑兰|tehran|阿巴斯|abbas|布什尔|bushehr|伊斯法罕|isfahan|纳坦兹|natanz 原文: {str(text)[:800]} @@ -82,7 +84,8 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A loss_us = {} loss_ir = {} for k in ["personnel_killed", "personnel_wounded", "civilian_killed", "civilian_wounded", - "bases_destroyed", "bases_damaged", "aircraft", "warships", "armor", "vehicles"]: + "bases_destroyed", "bases_damaged", "aircraft", "warships", "armor", "vehicles", + "drones", "missiles", "helicopters", "submarines"]: uk, ik = f"us_{k}", f"iran_{k}" if uk in parsed and isinstance(parsed[uk], (int, float)): loss_us[k] = max(0, int(parsed[uk])) diff --git a/crawler/extractor_rules.py b/crawler/extractor_rules.py index e067349..b70a7fd 100644 --- a/crawler/extractor_rules.py +++ b/crawler/extractor_rules.py @@ -36,6 +36,8 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A if v is not None: loss_us["personnel_killed"] = v v = _first_int(t, r"(\d+)\s*名?\s*(?:美军|美国)\s*受伤") + if v is None and ("美军" in (text or "") or "美国" in (text or "")): + v = _first_int(text or t, r"另有\s*(\d+)\s*人\s*受伤") if v is not None: loss_us["personnel_wounded"] = v v = _first_int(t, r"美军\s*伤亡\s*(\d+)") @@ -57,7 +59,7 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A 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*(?:死亡|阵亡)") + v = _first_int(t, r"(\d+)\s*名?\s*(?:伊朗|伊朗军队)[\s\w]*(?:死亡|阵亡)") if v is not None: loss_ir["personnel_killed"] = v v = _first_int(t, r"(\d+)\s*名?\s*伊朗\s*受伤") @@ -75,28 +77,42 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A if v is not None: loss_ir["personnel_wounded"] = v - # 平民伤亡(中英文) + # 平民伤亡(中英文,按阵营归属) 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 "伊朗" in text or "iran" in t: + loss_ir["civilian_killed"] = v + else: + 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 and loss_ir.get("civilian_killed") is None else None if v is not None: - loss_us["civilian_killed"] = v + if "iran" in t: + loss_ir["civilian_killed"] = v + else: + 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 + if "iran" in t: + loss_ir["civilian_wounded"] = v + else: + loss_us["civilian_wounded"] = v + v = _first_int(text or t, r"伊朗[\s\w]*(?:空袭|打击)[\s\w]*造成[^\d]*(\d+)[\s\w]*(?:平民|人|伤亡)") + if v is not None: + loss_ir["civilian_killed"] = 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 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 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 + # 基地损毁(仅匹配 base/基地,排除"军事目标"等泛指) + skip_bases = "军事目标" in (text or "") and "基地" not in (text or "") and "base" not in t + if not skip_bases: + 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 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 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 # 战机 / 舰船(根据上下文判断阵营) v = _first_int(t, r"(\d+)[\s\w]*(?:aircraft|plane|jet|fighter|f-?16|f-?35|f-?18)[\s\w]*(?:down|destroyed|lost|shot)") @@ -114,6 +130,48 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A else: loss_us["warships"] = v + # 无人机 drone / uav / 无人机 + v = _first_int(t, r"(\d+)[\s\w]*(?:drone|uav|无人机)[\s\w]*(?:down|destroyed|shot|击落|摧毁)") + if v is None: + v = _first_int(text or t, r"(?:击落|摧毁)[^\d]*(\d+)[\s\w]*(?:drone|uav|无人机|架)") + if v is None: + v = _first_int(t, r"(?:drone|uav|无人机)[\s\w]*(\d+)[\s\w]*(?:down|destroyed|shot|击落|摧毁)") + if v is not None: + if "iran" in t or "iranian" in t or "shahed" in t or "沙希德" in t or "伊朗" in (text or ""): + loss_ir["drones"] = v + else: + loss_us["drones"] = v + + # 导弹 missile / 导弹 + v = _first_int(t, r"(\d+)[\s\w]*(?:missile|导弹)[\s\w]*(?:fired|launched|intercepted|destroyed|发射|拦截|击落)") + if v is not None: + if "iran" in t or "iranian" in t: + loss_ir["missiles"] = v + else: + loss_us["missiles"] = v + v = _first_int(t, r"(?:missile|导弹)[\s\w]*(\d+)[\s\w]*(?:fired|launched|intercepted|destroyed|发射|拦截)") if not loss_us.get("missiles") and not loss_ir.get("missiles") else None + if v is not None: + if "iran" in t: + loss_ir["missiles"] = v + else: + loss_us["missiles"] = v + + # 直升机 helicopter / 直升机 + v = _first_int(t, r"(\d+)[\s\w]*(?:helicopter|直升机)[\s\w]*(?:down|destroyed|crashed|crashes|击落|坠毁)") + if v is not None: + if "iran" in t or "iranian" in t: + loss_ir["helicopters"] = v + else: + loss_us["helicopters"] = v + + # 潜艇 submarine / 潜艇 + v = _first_int(t, r"(\d+)[\s\w]*(?:submarine|潜艇)[\s\w]*(?:sunk|damaged|hit|destroyed|击沉|受损)") + if v is not None: + if "iran" in t or "iranian" in t: + loss_ir["submarines"] = v + else: + loss_us["submarines"] = v + if loss_us: out.setdefault("combat_losses_delta", {})["us"] = loss_us if loss_ir: @@ -124,11 +182,14 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A 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) + # 新闻提及基地遭袭时,更新对应基地 status;放宽触发词以匹配更多英文报道 + attack_words = ("attack" in t or "attacked" in t or "hit" in t or "strike" in t or "struck" in t or "strikes" in t + or "damage" in t or "damaged" in t or "target" in t or "targeted" in t or "bomb" in t or "bombed" in t + or "袭击" in (text or "") or "遭袭" in (text or "") or "打击" in (text or "") or "受损" in (text or "") or "摧毁" in (text or "")) + base_attacked = ("base" in t or "基地" in t or "outpost" in t or "facility" in t) and attack_words if base_attacked: updates: list = [] - # 常见美军基地关键词 -> name_keywords(用于 db_merge 的 LIKE 匹配) + # 常见美军基地关键词 -> name_keywords(用于 db_merge 的 LIKE 匹配,需与 key_location.name 能匹配) bases_all = [ ("阿萨德|阿因|asad|assad|ain", "us"), ("巴格达|baghdad", "us"), diff --git a/crawler/requirements.txt b/crawler/requirements.txt index 5facd77..427768e 100644 --- a/crawler/requirements.txt +++ b/crawler/requirements.txt @@ -1,5 +1,6 @@ requests>=2.31.0 feedparser>=6.0.0 +pytest>=7.0.0 fastapi>=0.109.0 uvicorn>=0.27.0 deep-translator>=1.11.0 diff --git a/crawler/tests/__init__.py b/crawler/tests/__init__.py new file mode 100644 index 0000000..ca23acf --- /dev/null +++ b/crawler/tests/__init__.py @@ -0,0 +1 @@ +# crawler tests diff --git a/crawler/tests/__pycache__/__init__.cpython-39.pyc b/crawler/tests/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..56b1bc7c9981e83189ff5da01f89c2c1fba7dc4f GIT binary patch literal 141 zcmYe~<>g`k0{^8;GX;S3V-N=!FakLaKwQiMBvKfH88jLFRx%WUgb~CqBmL0g)S_bj zl*GKu)Exbs%(Be9bp6m^NB!iY#PXcfBK?xo;*w(h`1s7c%#!$cy@JYH95%W6DWy57 Lb|AAp12F>t?jRrv literal 0 HcmV?d00001 diff --git a/crawler/tests/__pycache__/test_extraction.cpython-39-pytest-8.4.2.pyc b/crawler/tests/__pycache__/test_extraction.cpython-39-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..584254406e37f797d3c5e1b65a1582f84d4494c6 GIT binary patch literal 16348 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 literal 0 HcmV?d00001 diff --git a/crawler/tests/test_extraction.py b/crawler/tests/test_extraction.py new file mode 100644 index 0000000..05bd1de --- /dev/null +++ b/crawler/tests/test_extraction.py @@ -0,0 +1,198 @@ +# -*- coding: utf-8 -*- +""" +爬虫数据清洗与字段映射测试 +验证 extractor_rules、extractor_dashscope、db_merge 的正确性 +""" +import os +import sqlite3 +import tempfile +from pathlib import Path + +import pytest + +# 确保 crawler 在 path 中 +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in __import__("sys").path: + __import__("sys").path.insert(0, str(ROOT)) + +from extractor_rules import extract_from_news as extract_rules + + +class TestExtractorRules: + """规则提取器单元测试""" + + def test_trump_1000_targets_no_bases(self): + """特朗普说伊朗有1000个军事目标遭到袭击 -> 不应提取 bases_destroyed/bases_damaged""" + text = "特朗普说伊朗有1000个军事目标遭到袭击,美国已做好进一步打击准备" + out = extract_rules(text) + delta = out.get("combat_losses_delta", {}) + for side in ("us", "iran"): + if side in delta: + assert delta[side].get("bases_destroyed") is None, f"{side} bases_destroyed 不应被提取" + assert delta[side].get("bases_damaged") is None, f"{side} bases_damaged 不应被提取" + + def test_base_damaged_when_explicit(self): + """阿萨德基地遭袭 -> 应提取 key_location_updates,且 combat_losses 若有则正确""" + text = "阿萨德空军基地遭袭,损失严重" + out = extract_rules(text) + # 规则会触发 key_location_updates(因为 base_attacked 且匹配 阿萨德) + assert "key_location_updates" in out + kl = out["key_location_updates"] + assert len(kl) >= 1 + assert any(u.get("side") == "us" and "阿萨德" in (u.get("name_keywords") or "") for u in kl) + + def test_us_personnel_killed(self): + """3名美军阵亡 -> personnel_killed=3""" + text = "据报道,3名美军阵亡,另有5人受伤" + out = extract_rules(text) + assert "combat_losses_delta" in out + us = out["combat_losses_delta"].get("us", {}) + assert us.get("personnel_killed") == 3 + assert us.get("personnel_wounded") == 5 + + def test_iran_personnel_killed(self): + """10名伊朗士兵死亡""" + text = "伊朗方面称10名伊朗士兵死亡" + out = extract_rules(text) + iran = out.get("combat_losses_delta", {}).get("iran", {}) + assert iran.get("personnel_killed") == 10 + + def test_civilian_us_context(self): + """美军空袭造成50名平民伤亡 -> loss_us""" + text = "美军空袭造成50名平民伤亡" + out = extract_rules(text) + us = out.get("combat_losses_delta", {}).get("us", {}) + assert us.get("civilian_killed") == 50 + + def test_civilian_iran_context(self): + """伊朗空袭造成伊拉克平民50人伤亡 -> loss_ir""" + text = "伊朗空袭造成伊拉克平民50人伤亡" + out = extract_rules(text) + iran = out.get("combat_losses_delta", {}).get("iran", {}) + assert iran.get("civilian_killed") == 50 + + def test_drone_attribution_iran(self): + """美军击落伊朗10架无人机 -> iran drones=10""" + text = "美军击落伊朗10架无人机" + out = extract_rules(text) + iran = out.get("combat_losses_delta", {}).get("iran", {}) + assert iran.get("drones") == 10 + + def test_empty_or_short_text(self): + """短文本或无内容 -> 无 combat_losses""" + assert extract_rules("") == {} or "combat_losses_delta" not in extract_rules("") + assert "combat_losses_delta" not in extract_rules("abc") or not extract_rules("abc").get("combat_losses_delta") + + +class TestDbMerge: + """db_merge 字段映射与增量逻辑测试""" + + @pytest.fixture + def temp_db(self): + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + path = f.name + yield path + try: + os.unlink(path) + except OSError: + pass + + def test_merge_combat_losses_delta(self, temp_db): + """merge 正确将 combat_losses_delta 叠加到 DB""" + from db_merge import merge + + merge({"combat_losses_delta": {"us": {"personnel_killed": 3, "personnel_wounded": 2}}}, db_path=temp_db) + merge({"combat_losses_delta": {"us": {"personnel_killed": 2}}}, db_path=temp_db) + + conn = sqlite3.connect(temp_db) + row = conn.execute("SELECT personnel_killed, personnel_wounded FROM combat_losses WHERE side='us'").fetchone() + conn.close() + assert row[0] == 5 + assert row[1] == 2 + + def test_merge_all_combat_fields(self, temp_db): + """merge 正确映射所有 combat_losses 字段""" + from db_merge import merge + + delta = { + "personnel_killed": 1, + "personnel_wounded": 2, + "civilian_killed": 3, + "civilian_wounded": 4, + "bases_destroyed": 1, + "bases_damaged": 2, + "aircraft": 3, + "warships": 4, + "armor": 5, + "vehicles": 6, + "drones": 7, + "missiles": 8, + "helicopters": 9, + "submarines": 10, + } + merge({"combat_losses_delta": {"iran": delta}}, db_path=temp_db) + + conn = sqlite3.connect(temp_db) + row = conn.execute( + """SELECT personnel_killed, personnel_wounded, civilian_killed, civilian_wounded, + bases_destroyed, bases_damaged, aircraft, warships, armor, vehicles, + drones, missiles, helicopters, submarines FROM combat_losses WHERE side='iran'""" + ).fetchone() + conn.close() + assert row == (1, 2, 3, 4, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10) + + def test_merge_key_location_requires_table(self, temp_db): + """key_location_updates 需要 key_location 表中有行才能更新""" + from db_merge import merge + + conn = sqlite3.connect(temp_db) + conn.execute( + """CREATE TABLE IF NOT EXISTS key_location (id INTEGER PRIMARY KEY, side TEXT, name TEXT, lat REAL, lng REAL, type TEXT, region TEXT, status TEXT, damage_level INTEGER)""" + ) + conn.execute( + "INSERT INTO key_location (side, name, lat, lng, type, region, status, damage_level) VALUES ('us', '阿萨德空军基地', 33.0, 43.0, 'Base', 'IRQ', 'operational', 0)" + ) + conn.commit() + conn.close() + + merge( + {"key_location_updates": [{"name_keywords": "阿萨德|asad", "side": "us", "status": "attacked", "damage_level": 2}]}, + db_path=temp_db, + ) + + conn = sqlite3.connect(temp_db) + row = conn.execute("SELECT status, damage_level FROM key_location WHERE name LIKE '%阿萨德%'").fetchone() + conn.close() + assert row[0] == "attacked" + assert row[1] == 2 + + +class TestEndToEndTrumpExample: + """端到端:特朗普 1000 军事目标案例""" + + def test_full_pipeline_trump_no_bases(self, tmp_path): + """完整流程:规则提取 + merge,特朗普案例不应增加 bases""" + from db_merge import merge + + db_path = str(tmp_path / "test.db") + (tmp_path / "test.db").touch() # 确保文件存在,merge 才会执行 + merge({"combat_losses_delta": {"us": {"bases_destroyed": 0, "bases_damaged": 0}, "iran": {"bases_destroyed": 0, "bases_damaged": 0}}}, db_path=db_path) + + text = "特朗普说伊朗有1000个军事目标遭到袭击" + out = extract_rules(text) + # 规则提取不应包含 bases + assert "combat_losses_delta" not in out or ( + "iran" not in out.get("combat_losses_delta", {}) + or out["combat_losses_delta"].get("iran", {}).get("bases_destroyed") is None + and out["combat_losses_delta"].get("iran", {}).get("bases_damaged") is None + ) + if "combat_losses_delta" in out: + merge(out, db_path=db_path) + + conn = sqlite3.connect(db_path) + iran = conn.execute("SELECT bases_destroyed, bases_damaged FROM combat_losses WHERE side='iran'").fetchone() + conn.close() + # 若提取器没输出 bases,merge 不会改;若有错误输出则需要为 0 + if iran: + assert iran[0] == 0 + assert iran[1] == 0 diff --git a/docs/DATA_FLOW.md b/docs/DATA_FLOW.md new file mode 100644 index 0000000..1a5856c --- /dev/null +++ b/docs/DATA_FLOW.md @@ -0,0 +1,62 @@ +# 前端数据更新链路与字段映射 + +## 1. 前端数据点 + +| 组件 | 数据 | API 字段 | DB 表/列 | +|------|------|----------|----------| +| HeaderPanel | lastUpdated | situation.lastUpdated | situation.updated_at | +| HeaderPanel | powerIndex | usForces/iranForces.powerIndex | power_index | +| HeaderPanel | feedbackCount, shareCount | POST /api/feedback, /api/share | feedback, share_count | +| TimelinePanel | recentUpdates | situation.recentUpdates | situation_update | +| WarMap | keyLocations | usForces/iranForces.keyLocations | key_location | +| BaseStatusPanel | 基地统计 | keyLocations (status, damage_level) | key_location | +| CombatLossesPanel | 人员/平民伤亡 | combatLosses, civilianCasualtiesTotal | combat_losses | +| CombatLossesOtherPanel | 装备毁伤 | combatLosses (bases, aircraft, drones, …) | combat_losses | +| PowerChart | 雷达图 | powerIndex | power_index | +| WallStreetTrend | 美股趋势 | wallStreetInvestmentTrend | wall_street_trend | +| RetaliationGauge | 报复指数 | retaliationSentiment | retaliation_current/history | + +**轮询**: `fetchSituation()` 加载,WebSocket `/ws` 每 3 秒广播。`GET /api/situation` → `getSituation()`。 + +## 2. 爬虫 → DB 字段映射 + +| 提取器输出 | DB 表 | 逻辑 | +|------------|-------|------| +| situation_update | situation_update | INSERT | +| combat_losses_delta | combat_losses | 增量叠加 (ADD) | +| retaliation | retaliation_current, retaliation_history | REPLACE / APPEND | +| wall_street | wall_street_trend | INSERT | +| key_location_updates | key_location | UPDATE status, damage_level WHERE name LIKE | + +### combat_losses 字段对应 + +| 提取器 (us/iran) | DB 列 | +|------------------|-------| +| personnel_killed | personnel_killed | +| personnel_wounded | personnel_wounded | +| civilian_killed | civilian_killed | +| civilian_wounded | civilian_wounded | +| bases_destroyed | bases_destroyed | +| bases_damaged | bases_damaged | +| aircraft, warships, armor, vehicles | 同名 | +| drones, missiles, helicopters, submarines | 同名 | + +## 3. 测试用例 + +运行: `npm run crawler:test:extraction` + +| 用例 | 输入 | 预期 | +|------|------|------| +| 特朗普 1000 军事目标 | "特朗普说伊朗有1000个军事目标遭到袭击" | 不提取 bases_destroyed/bases_damaged | +| 阿萨德基地遭袭 | "阿萨德空军基地遭袭,损失严重" | 输出 key_location_updates | +| 美军伤亡 | "3名美军阵亡,另有5人受伤" | personnel_killed=3, personnel_wounded=5 | +| 伊朗平民 | "伊朗空袭造成伊拉克平民50人伤亡" | iran.civilian_killed=50 | +| 伊朗无人机 | "美军击落伊朗10架无人机" | iran.drones=10 | +| db_merge 增量 | 两次 merge 3+2 | personnel_killed=5 | + +## 4. 注意事项 + +- **bases_***: 仅指已确认损毁/受损的基地;"军事目标"/targets 不填 bases_*。 +- **正则 [\s\w]***: 会匹配数字,导致 (\d+) 只捕获末位;数字前用 `[^\d]*`。 +- **伊朗平民**: 规则已支持 "伊朗空袭造成…平民" 归入 loss_ir。 +- **key_location**: 需 name LIKE '%keyword%' 匹配,关键词见 extractor_rules.bases_all。 diff --git a/package.json b/package.json index 3345eb0..07e3cc5 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "crawler": "cd crawler && python main.py", "gdelt": "cd crawler && uvicorn realtime_conflict_service:app --host 0.0.0.0 --port 8000", "crawler:test": "cd crawler && python3 -c \"import sys; sys.path.insert(0,'.'); from scrapers.rss_scraper import fetch_all; n=len(fetch_all()); print('RSS 抓取:', n, '条' if n else '(0 条,检查网络或关键词过滤)')\"", + "crawler:test:extraction": "cd crawler && python3 -m pytest tests/test_extraction.py -v", "build": "vite build", "typecheck": "tsc --noEmit", "lint": "eslint .", diff --git a/server/data.db-shm b/server/data.db-shm index beb6dd2a947358a97b523300f8a5ee1259f026dc..14174d9eded66accd675e69bb0ef1099b43a9be6 100644 GIT binary patch literal 32768 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 Q_Jw%<=w~E#h`$p2A2<-zi2wiq literal 32768 zcmeI*NlrpR5XSKjC!9e+MV#jeoaYg73LE3vJGk>MF1&_u=>bfM{#{Dm~*ph(&0tg_000IagfB*srAb2v$=u_UGrfvG^ zLC#f-Kug6`TIxb$_QjMw^_RK~{vQDZ5b!`CVBdFYSJZ<{q(Gq31gcf5pc?G^Xq}Z- zI`txe00IagfB*srAb*T#{-MG_o9m5$2AmFFKJB?LB3jhEB diff --git a/server/data.db-wal b/server/data.db-wal index 60a97c7d0a05d2c477d13d020aa97d9e22523067..eebc6c7f879de784bbe3770d6be4218cf9d234cd 100644 GIT binary patch delta 18491 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 delta 228585 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%0D>7p}feVv3FR^0a5 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

C6|_x>6}%_dC3r@#KrmV05cC!p1@QtA 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 diff --git a/server/index.js b/server/index.js index 33535b7..66350fa 100644 --- a/server/index.js +++ b/server/index.js @@ -40,18 +40,22 @@ if (fs.existsSync(distPath)) { const server = http.createServer(app) +const { getStats } = require('./stats') + const wss = new WebSocketServer({ server, path: '/ws' }) wss.on('connection', (ws) => { - ws.send(JSON.stringify({ type: 'situation', data: getSituation() })) + ws.send(JSON.stringify({ type: 'situation', data: getSituation(), stats: getStats() })) }) + function broadcastSituation() { try { - const data = JSON.stringify({ type: 'situation', data: getSituation() }) + const data = JSON.stringify({ type: 'situation', data: getSituation(), stats: getStats() }) wss.clients.forEach((c) => { if (c.readyState === 1) c.send(data) }) } catch (_) {} } +app.set('broadcastSituation', broadcastSituation) setInterval(broadcastSituation, 3000) // 供爬虫调用:更新 situation.updated_at 并立即广播 diff --git a/server/routes.js b/server/routes.js index d0e6438..5d621be 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1,5 +1,6 @@ const express = require('express') const { getSituation } = require('./situationData') +const { getStats } = require('./stats') const db = require('./db') const router = express.Router() @@ -84,16 +85,6 @@ function getClientIp(req) { return req.ip || req.socket?.remoteAddress || 'unknown' } -function getStats() { - 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 - const feedbackCount = db.prepare('SELECT COUNT(*) as n FROM feedback').get().n ?? 0 - const shareCount = db.prepare('SELECT total FROM share_count WHERE id = 1').get()?.total ?? 0 - return { viewers, cumulative, feedbackCount, shareCount } -} - router.post('/visit', (req, res) => { try { const ip = getClientIp(req) @@ -103,6 +94,8 @@ router.post('/visit', (req, res) => { db.prepare( 'INSERT INTO visitor_count (id, total) VALUES (1, 1) ON CONFLICT(id) DO UPDATE SET total = total + 1' ).run() + const broadcast = req.app?.get?.('broadcastSituation') + if (typeof broadcast === 'function') broadcast() res.json(getStats()) } catch (err) { console.error(err) diff --git a/server/stats.js b/server/stats.js new file mode 100644 index 0000000..d943e6f --- /dev/null +++ b/server/stats.js @@ -0,0 +1,13 @@ +const db = require('./db') + +function getStats() { + 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 + const feedbackCount = db.prepare('SELECT COUNT(*) as n FROM feedback').get().n ?? 0 + const shareCount = db.prepare('SELECT total FROM share_count WHERE id = 1').get()?.total ?? 0 + return { viewers, cumulative, feedbackCount, shareCount } +} + +module.exports = { getStats } diff --git a/src/api/websocket.ts b/src/api/websocket.ts index 0adc10b..10e702e 100644 --- a/src/api/websocket.ts +++ b/src/api/websocket.ts @@ -15,7 +15,7 @@ export function connectSituationWebSocket(onData: Handler): () => void { ws.onmessage = (e) => { try { const msg = JSON.parse(e.data) - if (msg.type === 'situation' && msg.data) handler?.(msg.data) + if (msg.type === 'situation') handler?.({ situation: msg.data, stats: msg.stats }) } catch (_) {} } ws.onclose = () => { diff --git a/src/components/HeaderPanel.tsx b/src/components/HeaderPanel.tsx index a351c7f..c778991 100644 --- a/src/components/HeaderPanel.tsx +++ b/src/components/HeaderPanel.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { StatCard } from './StatCard' import { useSituationStore } from '@/store/situationStore' +import { useStatsStore } from '@/store/statsStore' import { useReplaySituation } from '@/hooks/useReplaySituation' import { usePlaybackStore } from '@/store/playbackStore' import { Wifi, WifiOff, Clock, Share2, Heart, Eye, MessageSquare } from 'lucide-react' @@ -23,10 +24,12 @@ export function HeaderPanel() { 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) - const [feedbackCount, setFeedbackCount] = useState(0) - const [shareCount, setShareCount] = useState(0) + const stats = useStatsStore((s) => s.stats) + const setStats = useStatsStore((s) => s.setStats) + const viewers = stats.viewers ?? 0 + const cumulative = stats.cumulative ?? 0 + const feedbackCount = stats.feedbackCount ?? 0 + const shareCount = stats.shareCount ?? 0 const [feedbackOpen, setFeedbackOpen] = useState(false) const [feedbackText, setFeedbackText] = useState('') const [feedbackSending, setFeedbackSending] = useState(false) @@ -41,13 +44,14 @@ export function HeaderPanel() { 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) - if (data.feedbackCount != null) setFeedbackCount(data.feedbackCount) - if (data.shareCount != null) setShareCount(data.shareCount) + setStats({ + viewers: data.viewers, + cumulative: data.cumulative, + feedbackCount: data.feedbackCount, + shareCount: data.shareCount, + }) } catch { - setViewers((v) => (v > 0 ? v : 0)) - setCumulative((c) => (c > 0 ? c : 0)) + setStats({ viewers: 0, cumulative: 0 }) } } @@ -79,7 +83,7 @@ export function HeaderPanel() { try { const res = await fetch('/api/share', { method: 'POST' }) const data = await res.json() - if (data.shareCount != null) setShareCount(data.shareCount) + if (data.shareCount != null) setStats({ shareCount: data.shareCount }) } catch {} } } @@ -102,7 +106,7 @@ export function HeaderPanel() { if (data.ok) { setFeedbackText('') setFeedbackDone(true) - setFeedbackCount((c) => c + 1) + setStats({ feedbackCount: (feedbackCount ?? 0) + 1 }) setTimeout(() => { setFeedbackOpen(false) setFeedbackDone(false) diff --git a/src/store/situationStore.ts b/src/store/situationStore.ts index 870c09f..d52d67a 100644 --- a/src/store/situationStore.ts +++ b/src/store/situationStore.ts @@ -3,6 +3,7 @@ import type { MilitarySituation } from '@/data/mockData' import { INITIAL_MOCK_DATA } from '@/data/mockData' import { fetchSituation } from '@/api/situation' import { connectSituationWebSocket } from '@/api/websocket' +import { useStatsStore } from './statsStore' interface SituationState { situation: MilitarySituation @@ -60,9 +61,11 @@ function pollSituation() { export function startSituationWebSocket(): () => void { useSituationStore.getState().setLastError(null) - disconnectWs = connectSituationWebSocket((data) => { + disconnectWs = connectSituationWebSocket((payload) => { + const { situation, stats } = payload as { situation?: MilitarySituation; stats?: { viewers?: number; cumulative?: number; feedbackCount?: number; shareCount?: number } } useSituationStore.getState().setConnected(true) - useSituationStore.getState().setSituation(data as MilitarySituation) + if (situation) useSituationStore.getState().setSituation(situation) + if (stats) useStatsStore.getState().setStats(stats) }) pollSituation() diff --git a/src/store/statsStore.ts b/src/store/statsStore.ts new file mode 100644 index 0000000..4290524 --- /dev/null +++ b/src/store/statsStore.ts @@ -0,0 +1,18 @@ +import { create } from 'zustand' + +export interface Stats { + viewers?: number + cumulative?: number + feedbackCount?: number + shareCount?: number +} + +interface StatsState { + stats: Stats + setStats: (stats: Stats) => void +} + +export const useStatsStore = create((set) => ({ + stats: {}, + setStats: (stats) => set((s) => ({ stats: { ...s.stats, ...stats } })), +}))