From 36576592a27f03d534bbd8d949e66dbe5ceb1ce9 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 2 Mar 2026 14:10:43 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96docker=20=E9=95=9C?= =?UTF-8?q?=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 13 +++ Dockerfile | 25 +++++ Dockerfile.crawler | 17 ++++ README.md | 19 ++++ crawler/__pycache__/config.cpython-39.pyc | Bin 1295 -> 2344 bytes crawler/__pycache__/db_merge.cpython-39.pyc | Bin 5226 -> 6287 bytes .../__pycache__/extractor_ai.cpython-39.pyc | Bin 3519 -> 4444 bytes .../extractor_rules.cpython-39.pyc | Bin 0 -> 3221 bytes .../realtime_conflict_service.cpython-39.pyc | Bin 11098 -> 11489 bytes crawler/config.py | 36 ++++++- crawler/db_merge.py | 58 ++++++++--- crawler/extractor_ai.py | 22 +++- crawler/extractor_rules.py | 54 +++++++++- crawler/realtime_conflict_service.py | 52 ++++++---- crawler/requirements.txt | 1 - docker-compose.yml | 33 ++++++ docker-entrypoint.sh | 8 ++ docs/DOCKER_MIRROR.md | 30 ++++++ server/data.db-shm | Bin 32768 -> 32768 bytes server/data.db-wal | Bin 3600912 -> 4120032 bytes server/db.js | 17 +++- server/index.js | 14 +++ server/routes.js | 40 ++++++++ src/components/HeaderPanel.tsx | 96 ++++++++++++++++-- src/components/WarMap.tsx | 14 +-- 25 files changed, 491 insertions(+), 58 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 Dockerfile.crawler create mode 100644 crawler/__pycache__/extractor_rules.cpython-39.pyc create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 docs/DOCKER_MIRROR.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8fc4c53 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +.git +.env +.env.local +*.log +dist +server/data.db +.DS_Store +*.md +.cursor +.venv +__pycache__ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4fa2dc0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# 前端 + API 一体化镜像(使用 DaoCloud 国内镜像源) +FROM docker.m.daocloud.io/library/node:20-alpine AS frontend-builder +WORKDIR /app +ARG VITE_MAPBOX_ACCESS_TOKEN +ENV VITE_MAPBOX_ACCESS_TOKEN=${VITE_MAPBOX_ACCESS_TOKEN} +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM docker.m.daocloud.io/library/node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY --from=frontend-builder /app/dist ./dist +COPY server ./server + +ENV NODE_ENV=production +ENV API_PORT=3001 +ENV DB_PATH=/data/data.db +EXPOSE 3001 + +COPY docker-entrypoint.sh ./ +RUN chmod +x docker-entrypoint.sh +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/Dockerfile.crawler b/Dockerfile.crawler new file mode 100644 index 0000000..c975465 --- /dev/null +++ b/Dockerfile.crawler @@ -0,0 +1,17 @@ +# Python 爬虫服务(使用 DaoCloud 国内镜像源 + 清华 PyPI 源) +FROM docker.m.daocloud.io/library/python:3.11-slim + +WORKDIR /app +COPY crawler/requirements.txt ./ +RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt +COPY crawler ./ + +ENV DB_PATH=/data/data.db +ENV API_BASE=http://api:3001 +ENV CLEANER_AI_DISABLED=1 +ENV GDELT_DISABLED=1 +ENV RSS_INTERVAL_SEC=60 + +EXPOSE 8000 + +CMD ["uvicorn", "realtime_conflict_service:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 0c3c129..6f49cbd 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,25 @@ npm run dev npm run build ``` +## Docker 部署 + +```bash +# 构建并启动(需 .env 中配置 VITE_MAPBOX_ACCESS_TOKEN 以启用地图) +docker compose up -d + +# 访问前端:http://localhost:3001 +# 数据库与爬虫共享 volume,首次启动自动 seed +``` + +**拉取镜像超时?** 在 Docker Desktop 配置镜像加速,见 [docs/DOCKER_MIRROR.md](docs/DOCKER_MIRROR.md) + +环境变量(可选,在 .env 或 docker-compose.yml 中配置): + +- `VITE_MAPBOX_ACCESS_TOKEN`:Mapbox 令牌,构建时注入 +- `DB_PATH`:数据库路径(默认 /data/data.db) +- `CLEANER_AI_DISABLED=1`:爬虫默认禁用 Ollama +- `GDELT_DISABLED=1`:爬虫默认禁用 GDELT(国内易超时) + ## Project Structure ``` diff --git a/crawler/__pycache__/config.cpython-39.pyc b/crawler/__pycache__/config.cpython-39.pyc index 88f7772005061c99360970d433938f0edb3f81c0..cad6fbddc097cf3f3af930ee654e2bbeff1991b3 100644 GIT binary patch delta 1182 zcmX|=O-~y~7{@EwyEX*Uq{m2+e6G|>RQ29tZ&i=e(=0pjPFU~4>`p81#YD&w#vz6p z#Q`=kNpNC^wm9?!{DS!kJ#*e!d%s1WnKiL2d-TjcZ~xza8;jkq?&9U;#V_FV^ZgF} zap}|D?=qDDWhuiB%E?~}2i+$Y9Qgt~CngukXirr8C}(ibf{QeKxfzW%M1LP?*f6lC zg2~Hkaq)Nj;tc83W0=?~8jU)$+PTawMvUO_uq35#`s2J!adJtnEJAr<+2hF z_)JkIP8k*Kxwz0E;`7gGC>+)2DQ~(qP;0gob8MH zZ%6|H8NPj3ygP+5Nb|~!&V!8uvDJ%)hbV7Wq=YNnORJoGNP53RLh`DIvL>NSg`?3s zC_e4HP<%YhO{(OM&kv@x0n!;QV>6W+>Ly23F#$O1*vE6YJ8#6BeVLp!U2Bf&8t!# z5ICj&VNZ0Q$^o;Atb~-3MOvAZNSauQ2JNKPMfww$nE)(B`95yF2Pc-KBb2fJtbrp? z&L#$S+|yp2O*Vsnd3Z~3W`;9lISD>Lso!k98*P^opz@AXUxUn1Lu>B48 zuqh7(c~JMT?rgUUuUwFaFbAM%JQ_TcOeYsCpMn0(|1v*RG?TdwK^cyJQMqyJI2sP) zaER_O;$xd)pTg!+jd;5wPIhjxU^9}A+a8Lrf1~@%b=N$kssEdt=h`ME3@xk?=DChT zY@}N-yfsI<3AT8Raw_ya^-zYIkEQ>ME9txTsweiY#Lj8*vK`!8$DT(@1*l0VV4>=f bS-mlP{t3{C{GD*My_f&KJ* delta 108 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 delta 576 zcmYL`O=uHA6vyXdv)PYqb`vcm7^tbzxFR*FMFhQ6L4;lk5sDrR!52$vG-0xk3L89V z6!C-Lp!K6-7W5!eR08&5@t`O1EEqj@IvfrAxydAQ0)0G1$~t) zVlcpjSQUe{s`wbBmPITct;<{dH!6_*`}!0L>v>*9OC{r zeG{ubEh0C~Z^nK*(<0AiBo0ax`dw=Rr;bQ{Sm3nTD%WbIrpGE3&vp2$J?`Y?;iQE8 zA(rJ0yP&%!bL6hTKiD&S*p$>G>=$Z~s4RP|eAX+S6K$6+R$9$NBHcXBUFQVFY>wY@ p(ndyBl6sTn_$z1rz{o+i&*BnUiK7xD|2e7tyZp1_(+L+({sqW0nYaJ| diff --git a/crawler/__pycache__/extractor_rules.cpython-39.pyc b/crawler/__pycache__/extractor_rules.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b3358bbfd7240eddf9a06f7446c6a64ea2c22c4c GIT binary patch literal 3221 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 M$B$IS<#3?sza0|!egFUf delta 1673 zcmZuxO>7%Q6rP#gtk-{utDVe`r#ux)L(3-(cc6>$2Vi z60Pfl+XIKRL3;v%v=%Bd76$XEuS`qFqbk->{0ceG`EviI$v=HM#kn+@-fh_^GZr)X+R(G8gdI~*C zFQ)}&3DC~_crOII33POm8m%*>-_}%76@oCrfu>;uIuXVBU=v3nR%bP;9N1&Jpxa>% z?lC=0W~k}Y3w;=4xQY55|_wrPX@KH$45WLGEjO;K-!RSL#Im}bsXKUdwa$|4=xiRz~-=T06 zCJ-IrQehmKW2iq4lgJ!>8Lx`u2_#R#6p|B7r&BPE=oprSB6E62P>we>GjIkqll%nY zv*>pYW)Yv{p&C)9_*CH(pM>*#2+}YI^II)g4x2lI+`;{vHC-pDi&|9y7Vy>=;NnB9 zigFq$0WKkP8Ll|Y3{N@txC+;Bqcf;K3)fM91Fk#za}IM8nOESZ!_4yYaBGEaGwnC~ z_{Fr6=IO#5kHR7>!EL)ofGv^at7{(+vMUJ^9ZS+iM>?BZ$!n!G6_#>uaab#IZ8My` zU;Y=2?Mb^~@w=G$L(Jb}P(lo%1$0uN7SpkQeBkdS4%6x|+u-XY6-oCSlGR$rbU@n*(LQ~<=-346wBiC(D9Lw- z8WM@#xlK3l>$AsQqOx&fFheqA2yL;7$M9%HgBl)PfbbU6iV&%YcpMQAFsnyv=z~#? zH>0dmQt?u<*`>#z)9`*oK4t?o{9Nx~U_7_?fmijse2ricakn2wsui zNHXmSYBX++k3<8{QGC`? fz)Q(|JUZ2%l4uBrEXmXp!Rc=8z|}@B51Ri21Hz_q diff --git a/crawler/config.py b/crawler/config.py index eb117d6..79ebb29 100644 --- a/crawler/config.py +++ b/crawler/config.py @@ -39,11 +39,37 @@ RSS_FEEDS = [ "https://www.aljazeera.com/xml/rss/middleeast.xml", ] -# 关键词过滤:至少匹配一个才会入库 +# 关键词过滤:至少匹配一个才会入库(与地图区域对应:伊拉克/叙利亚/海湾/红海/地中海等) KEYWORDS = [ - "iran", "iranian", "tehran", "以色列", "israel", - "usa", "us ", "american", "美军", "美国", - "middle east", "中东", "persian gulf", "波斯湾", + # 伊朗 + "iran", "iranian", "tehran", "德黑兰", "bushehr", "布什尔", "abbas", "阿巴斯", + # 以色列 / 巴勒斯坦 + "israel", "以色列", "hamas", "gaza", "加沙", "hezbollah", "真主党", + # 美国 + "usa", "us ", "american", "美军", "美国", "pentagon", + # 区域(地图覆盖) + "middle east", "中东", "persian gulf", "波斯湾", "gulf of oman", "阿曼湾", + "arabian sea", "阿拉伯海", "red sea", "红海", "mediterranean", "地中海", + "strait of hormuz", "霍尔木兹", + # 伊拉克 / 叙利亚 + "iraq", "伊拉克", "baghdad", "巴格达", "erbil", "埃尔比勒", "basra", "巴士拉", + "syria", "叙利亚", "damascus", "大马士革", "deir", "代尔祖尔", + # 海湾国家 + "saudi", "沙特", "riyadh", "利雅得", "qatar", "卡塔尔", "doha", "多哈", + "uae", "emirates", "阿联酋", "dubai", "迪拜", "abu dhabi", + "bahrain", "巴林", "kuwait", "科威特", "oman", "阿曼", "yemen", "也门", + # 约旦 / 土耳其 / 埃及 / 吉布提 / 黎巴嫩 + "jordan", "约旦", "amman", "安曼", + "lebanon", "黎巴嫩", + "turkey", "土耳其", "incirlik", "因吉尔利克", + "egypt", "埃及", "cairo", "开罗", "sinai", "西奈", + "djibouti", "吉布提", + # 军事 / 基地 + "al-asad", "al asad", "阿萨德", "al udeid", "乌代德", "incirlik", "strike", "attack", "military", "missile", "核", "nuclear", - "carrier", "航母", "houthi", "胡塞", "hamas", + "carrier", "航母", "drone", "uav", "无人机", "retaliation", "报复", + "base", "基地", "troops", "troop", "soldier", "personnel", + # 胡塞 / 武装 / 军力 + "houthi", "胡塞", "houthis", + "idf", "irgc", "革命卫队", "qassem soleimani", "苏莱曼尼", ] diff --git a/crawler/db_merge.py b/crawler/db_merge.py index 11f6b14..c8464c3 100644 --- a/crawler/db_merge.py +++ b/crawler/db_merge.py @@ -67,7 +67,7 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool: ) if conn.total_changes > 0: updated = True - # combat_losses:增量叠加到当前值 + # combat_losses:增量叠加到当前值,无行则先插入初始行 if "combat_losses_delta" in extracted: for side, delta in extracted["combat_losses_delta"].items(): if side not in ("us", "iran"): @@ -77,13 +77,14 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool: "SELECT personnel_killed,personnel_wounded,civilian_killed,civilian_wounded,bases_destroyed,bases_damaged,aircraft,warships,armor,vehicles FROM combat_losses WHERE side = ?", (side,), ).fetchone() - if not row: - continue - cur = { - "personnel_killed": row[0], "personnel_wounded": row[1], "civilian_killed": row[2] or 0, - "civilian_wounded": row[3] or 0, "bases_destroyed": row[4], "bases_damaged": row[5], - "aircraft": row[6], "warships": row[7], "armor": row[8], "vehicles": row[9], - } + cur = {"personnel_killed": 0, "personnel_wounded": 0, "civilian_killed": 0, "civilian_wounded": 0, + "bases_destroyed": 0, "bases_damaged": 0, "aircraft": 0, "warships": 0, "armor": 0, "vehicles": 0} + if row: + cur = { + "personnel_killed": row[0], "personnel_wounded": row[1], "civilian_killed": row[2] or 0, + "civilian_wounded": row[3] or 0, "bases_destroyed": row[4], "bases_damaged": row[5], + "aircraft": row[6], "warships": row[7], "armor": row[8], "vehicles": row[9], + } pk = max(0, (cur["personnel_killed"] or 0) + delta.get("personnel_killed", 0)) pw = max(0, (cur["personnel_wounded"] or 0) + delta.get("personnel_wounded", 0)) ck = max(0, (cur["civilian_killed"] or 0) + delta.get("civilian_killed", 0)) @@ -95,11 +96,18 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool: ar = max(0, (cur["armor"] or 0) + delta.get("armor", 0)) vh = max(0, (cur["vehicles"] or 0) + delta.get("vehicles", 0)) ts = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z") - conn.execute( - """UPDATE combat_losses SET personnel_killed=?, personnel_wounded=?, civilian_killed=?, civilian_wounded=?, - bases_destroyed=?, bases_damaged=?, aircraft=?, warships=?, armor=?, vehicles=?, updated_at=? WHERE side=?""", - (pk, pw, ck, cw, bd, bm, ac, ws, ar, vh, ts, side), - ) + if row: + conn.execute( + """UPDATE combat_losses SET personnel_killed=?, personnel_wounded=?, civilian_killed=?, civilian_wounded=?, + bases_destroyed=?, bases_damaged=?, aircraft=?, warships=?, armor=?, vehicles=?, updated_at=? WHERE side=?""", + (pk, pw, ck, cw, bd, bm, ac, ws, ar, vh, ts, side), + ) + else: + conn.execute( + """INSERT OR REPLACE INTO combat_losses (side, personnel_killed, personnel_wounded, civilian_killed, civilian_wounded, + bases_destroyed, bases_damaged, aircraft, warships, armor, vehicles, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (side, pk, pw, ck, cw, bd, bm, ac, ws, ar, vh, ts), + ) if conn.total_changes > 0: updated = True except Exception: @@ -115,6 +123,30 @@ def merge(extracted: Dict[str, Any], db_path: Optional[str] = None) -> bool: w = extracted["wall_street"] conn.execute("INSERT INTO wall_street_trend (time, value) VALUES (?, ?)", (w["time"], w["value"])) updated = True + # key_location:更新受袭基地 status/damage_level + if "key_location_updates" in extracted: + try: + for u in extracted["key_location_updates"]: + kw = (u.get("name_keywords") or "").replace("|", " ").split() + side = u.get("side") + status = u.get("status", "attacked")[:20] + dmg = u.get("damage_level", 2) + if not kw or side not in ("us", "iran"): + continue + conditions = " OR ".join( + "(LOWER(name) LIKE ? OR name LIKE ?)" for _ in kw + ) + params = [status, dmg, side] + for k in kw: + params.extend([f"%{k}%", f"%{k}%"]) + cur = conn.execute( + f"UPDATE key_location SET status=?, damage_level=? WHERE side=? AND ({conditions})", + params, + ) + if cur.rowcount > 0: + updated = True + except Exception: + pass if updated: conn.execute("INSERT OR REPLACE INTO situation (id, data, updated_at) VALUES (1, '{}', ?)", (datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"),)) conn.commit() diff --git a/crawler/extractor_ai.py b/crawler/extractor_ai.py index 146cc4d..cd38e80 100644 --- a/crawler/extractor_ai.py +++ b/crawler/extractor_ai.py @@ -27,11 +27,16 @@ def _call_ollama_extract(text: str, timeout: int = 10) -> Optional[Dict[str, Any - summary: 1-2句中文事实,≤80字 - category: deployment|alert|intel|diplomatic|other - severity: low|medium|high|critical -- us_personnel_killed, iran_personnel_killed 等:仅当新闻明确提及具体数字时填写 +- 战损(仅当新闻明确提及数字时填写,格式 us_XXX / iran_XXX): + us_personnel_killed, iran_personnel_killed, us_personnel_wounded, iran_personnel_wounded, + us_civilian_killed, iran_civilian_killed, us_civilian_wounded, iran_civilian_wounded, + us_bases_destroyed, iran_bases_destroyed, us_bases_damaged, iran_bases_damaged, + us_aircraft, iran_aircraft, us_warships, iran_warships, us_armor, iran_armor, us_vehicles, iran_vehicles - retaliation_sentiment: 0-100,仅当新闻涉及伊朗报复情绪时 - wall_street_value: 0-100,仅当新闻涉及美股/市场反应时 +- key_location_updates: 当新闻提及具体基地/地点遭袭时,数组项 { "name_keywords": "asad|阿萨德|assad", "side": "us", "status": "attacked", "damage_level": 1-3 } -原文:{str(text)[:500]} +原文:{str(text)[:800]} 直接输出 JSON,不要解释:""" r = requests.post( @@ -97,4 +102,17 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A v = parsed["wall_street_value"] if isinstance(v, (int, float)) and 0 <= v <= 100: out["wall_street"] = {"time": ts, "value": int(v)} + # key_location_updates:受袭基地 + if "key_location_updates" in parsed and isinstance(parsed["key_location_updates"], list): + valid = [] + for u in parsed["key_location_updates"]: + if isinstance(u, dict) and u.get("name_keywords") and u.get("side") in ("us", "iran"): + valid.append({ + "name_keywords": str(u["name_keywords"]), + "side": u["side"], + "status": str(u.get("status", "attacked"))[:20], + "damage_level": min(3, max(1, int(u["damage_level"]))) if isinstance(u.get("damage_level"), (int, float)) else 2, + }) + if valid: + out["key_location_updates"] = valid return out diff --git a/crawler/extractor_rules.py b/crawler/extractor_rules.py index 36c8227..89a1ee4 100644 --- a/crawler/extractor_rules.py +++ b/crawler/extractor_rules.py @@ -24,18 +24,64 @@ def extract_from_news(text: str, timestamp: Optional[str] = None) -> Dict[str, A t = (text or "").lower() loss_us, loss_ir = {}, {} - v = _first_int(t, r"(?:us|american|u\.?s\.?)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|killed|dead)") + + # 美军人员伤亡 + v = _first_int(t, r"(?:us|american|u\.?s\.?)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|military)[\s\w]*(?:killed|dead)") if v is not None: loss_us["personnel_killed"] = v - v = _first_int(t, r"(\d+)[\s\w]*(?:us|american)[\s\w]*(?:troop|soldier|killed|dead)") + v = _first_int(t, r"(\d+)[\s\w]*(?:us|american)[\s\w]*(?:troop|soldier|military)[\s\w]*(?:killed|dead)") if v is not None: loss_us["personnel_killed"] = v - v = _first_int(t, r"(?:iran|iranian)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|killed|dead)") + v = _first_int(t, r"(?:us|american)[\s\w]*(\d+)[\s\w]*(?:wounded|injured)") + if v is not None: + loss_us["personnel_wounded"] = v + + # 伊朗人员伤亡 + v = _first_int(t, r"(?:iran|iranian)[\s\w]*(?:say|report)[\s\w]*(\d+)[\s\w]*(?:troop|soldier|guard|killed|dead)") if v is not None: loss_ir["personnel_killed"] = v - v = _first_int(t, r"(\d+)[\s\w]*(?:iranian|iran)[\s\w]*(?:troop|soldier|killed|dead)") + v = _first_int(t, r"(\d+)[\s\w]*(?:iranian|iran)[\s\w]*(?:troop|soldier|guard|killed|dead)") if v is not None: loss_ir["personnel_killed"] = v + v = _first_int(t, r"(?:iran|iranian)[\s\w]*(\d+)[\s\w]*(?:wounded|injured)") + if v is not None: + loss_ir["personnel_wounded"] = v + + # 平民伤亡(多不区分阵营,计入双方或仅 us 因多为美国基地周边) + v = _first_int(t, r"(\d+)[\s\w]*(?:civilian|civil)[\s\w]*(?:killed|dead)") + if v is not None: + loss_us["civilian_killed"] = v + v = _first_int(t, r"(\d+)[\s\w]*(?:civilian|civil)[\s\w]*(?:wounded|injured)") + if v is not None: + loss_us["civilian_wounded"] = v + + # 基地损毁(美方基地居多) + v = _first_int(t, r"(\d+)[\s\w]*(?:base)[\s\w]*(?:destroyed|leveled)") + if v is not None: + loss_us["bases_destroyed"] = v + v = _first_int(t, r"(\d+)[\s\w]*(?:base)[\s\w]*(?:damaged|hit|struck)") + if v is not None: + loss_us["bases_damaged"] = v + if "base" in t and ("destroy" in t or "level" in t) and not loss_us.get("bases_destroyed"): + loss_us["bases_destroyed"] = 1 + if "base" in t and ("damage" in t or "hit" in t or "struck" in t or "strike" in t) and not loss_us.get("bases_damaged"): + loss_us["bases_damaged"] = 1 + + # 战机 / 舰船(根据上下文判断阵营) + v = _first_int(t, r"(\d+)[\s\w]*(?:aircraft|plane|jet|fighter|f-?16|f-?35|f-?18)[\s\w]*(?:down|destroyed|lost|shot)") + if v is not None: + if "us" in t or "american" in t or "u.s" in t: + loss_us["aircraft"] = v + elif "iran" in t: + loss_ir["aircraft"] = v + else: + loss_us["aircraft"] = v + v = _first_int(t, r"(\d+)[\s\w]*(?:ship|destroyer|warship|vessel)[\s\w]*(?:hit|damaged|sunk)") + if v is not None: + if "iran" in t: + loss_ir["warships"] = v + else: + loss_us["warships"] = v if loss_us: out.setdefault("combat_losses_delta", {})["us"] = loss_us diff --git a/crawler/realtime_conflict_service.py b/crawler/realtime_conflict_service.py index 6e278f5..ba2004a 100644 --- a/crawler/realtime_conflict_service.py +++ b/crawler/realtime_conflict_service.py @@ -14,13 +14,13 @@ from datetime import datetime from pathlib import Path from typing import List, Optional +import asyncio import logging import requests from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from apscheduler.schedulers.background import BackgroundScheduler -logging.getLogger("apscheduler.scheduler").setLevel(logging.ERROR) +logging.getLogger("uvicorn").setLevel(logging.INFO) app = FastAPI(title="GDELT Conflict Service") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"]) @@ -263,8 +263,9 @@ def _extract_and_merge_panel_data(items: list) -> None: from extractor_ai import extract_from_news from datetime import timezone merged_any = False - # 只对前几条有足够文本的新闻做提取,避免 Ollama 调用过多 - for it in items[:5]: + # 规则模式可多处理几条(无 Ollama);AI 模式限制 5 条避免调用过多 + limit = 10 if os.environ.get("CLEANER_AI_DISABLED", "0") == "1" else 5 + for it in items[:limit]: text = (it.get("title", "") or "") + " " + (it.get("summary", "") or "") if len(text.strip()) < 20: continue @@ -292,12 +293,22 @@ def _extract_and_merge_panel_data(items: list) -> None: # ========================== -# 定时任务(RSS 更频繁,优先保证事件脉络实时) +# 定时任务(asyncio 后台任务,避免 APScheduler executor 关闭竞态) # ========================== -scheduler = BackgroundScheduler() -scheduler.add_job(fetch_news, "interval", seconds=RSS_INTERVAL_SEC, max_instances=2, coalesce=True) -scheduler.add_job(fetch_gdelt_events, "interval", seconds=FETCH_INTERVAL_SEC, max_instances=2, coalesce=True) -scheduler.start() +_bg_task: Optional[asyncio.Task] = None + + +async def _periodic_fetch() -> None: + loop = asyncio.get_event_loop() + while True: + try: + await loop.run_in_executor(None, fetch_news) + await loop.run_in_executor(None, fetch_gdelt_events) + except asyncio.CancelledError: + break + except Exception as e: + print(f" [warn] 定时抓取: {e}") + await asyncio.sleep(min(RSS_INTERVAL_SEC, FETCH_INTERVAL_SEC)) # ========================== @@ -356,18 +367,23 @@ def _get_conflict_stats() -> dict: @app.on_event("startup") -def startup(): - # 新闻优先启动,确保事件脉络有数据 - fetch_news() - fetch_gdelt_events() +async def startup(): + global _bg_task + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, fetch_news) + await loop.run_in_executor(None, fetch_gdelt_events) + _bg_task = asyncio.create_task(_periodic_fetch()) @app.on_event("shutdown") -def shutdown(): - try: - scheduler.shutdown(wait=False) - except Exception: - pass +async def shutdown(): + global _bg_task + if _bg_task and not _bg_task.done(): + _bg_task.cancel() + try: + await _bg_task + except asyncio.CancelledError: + pass if __name__ == "__main__": diff --git a/crawler/requirements.txt b/crawler/requirements.txt index 0268df8..e1a5d6e 100644 --- a/crawler/requirements.txt +++ b/crawler/requirements.txt @@ -2,5 +2,4 @@ requests>=2.31.0 feedparser>=6.0.0 fastapi>=0.109.0 uvicorn>=0.27.0 -apscheduler>=3.10.0 deep-translator>=1.11.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bb3677c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + api: + build: + context: . + args: + - VITE_MAPBOX_ACCESS_TOKEN=${VITE_MAPBOX_ACCESS_TOKEN:-} + ports: + - "3001:3001" + environment: + - DB_PATH=/data/data.db + - API_PORT=3001 + volumes: + - app-data:/data + restart: unless-stopped + + crawler: + build: + context: . + dockerfile: Dockerfile.crawler + environment: + - DB_PATH=/data/data.db + - API_BASE=http://api:3001 + - CLEANER_AI_DISABLED=1 + - GDELT_DISABLED=1 + - RSS_INTERVAL_SEC=60 + volumes: + - app-data:/data + depends_on: + - api + restart: unless-stopped + +volumes: + app-data: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..7b05efd --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e +export DB_PATH="${DB_PATH:-/data/data.db}" +if [ ! -f "$DB_PATH" ]; then + echo "==> Seeding database..." + node server/seed.js +fi +exec node server/index.js diff --git a/docs/DOCKER_MIRROR.md b/docs/DOCKER_MIRROR.md new file mode 100644 index 0000000..e8e20dd --- /dev/null +++ b/docs/DOCKER_MIRROR.md @@ -0,0 +1,30 @@ +# Docker 拉取超时 / 配置镜像加速 + +国内环境从 Docker Hub 拉取镜像常超时,需在 Docker 中配置镜像加速。 + +## Docker Desktop(macOS / Windows) + +1. 打开 **Docker Desktop** +2. **Settings** → **Docker Engine** +3. 在 JSON 中增加 `registry-mirrors`(若已有其他配置,只需合并进该字段): + +```json +{ + "registry-mirrors": [ + "https://docker.m.daocloud.io", + "https://docker.1ms.run" + ] +} +``` + +4. 点击 **Apply & Restart** +5. 重新执行:`docker compose up -d --build` + +## 备选镜像源 + +可替换或补充到 `registry-mirrors` 中: + +- `https://docker.m.daocloud.io`(DaoCloud) +- `https://docker.1ms.run` +- `https://docker.rainbond.cc`(好雨科技) +- 阿里云 / 腾讯云等:在对应云控制台的「容器镜像服务」中获取个人专属加速地址 diff --git a/server/data.db-shm b/server/data.db-shm index 45a988214684d90dff41c9c617f80345fe4b15f4..db4083471e5fc62660f40179fd0321fe8c15661e 100644 GIT binary patch literal 32768 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 E2WG1#t^fc4 diff --git a/server/data.db-wal b/server/data.db-wal index f85fcc0fe8735d8ffd9d2e45f10ba669aa470643..1bd86b3dd91e72e8b822a7a272375159a6e4c2f8 100644 GIT binary patch delta 194722 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 iwMn^vmxY)%gh63U8*26*Je(*VLzt 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 diff --git a/server/db.js b/server/db.js index 63b924a..f7d77b5 100644 --- a/server/db.js +++ b/server/db.js @@ -1,7 +1,7 @@ const Database = require('better-sqlite3') const path = require('path') -const dbPath = path.join(__dirname, 'data.db') +const dbPath = process.env.DB_PATH || path.join(__dirname, 'data.db') const db = new Database(dbPath) // 启用外键 @@ -147,4 +147,19 @@ addUpdatedAt('force_asset') addUpdatedAt('key_location') addUpdatedAt('retaliation_current') +// 来访统计:visits 用于在看(近期活跃 IP),visitor_count 用于累积人次(每次接入 +1) +try { + db.exec(` + CREATE TABLE IF NOT EXISTS visits ( + ip TEXT PRIMARY KEY, + last_seen TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS visitor_count ( + id INTEGER PRIMARY KEY CHECK (id = 1), + total INTEGER NOT NULL DEFAULT 0 + ); + INSERT OR IGNORE INTO visitor_count (id, total) VALUES (1, 0); + `) +} catch (_) {} + module.exports = db diff --git a/server/index.js b/server/index.js index cd8638b..395d3d5 100644 --- a/server/index.js +++ b/server/index.js @@ -1,4 +1,6 @@ const http = require('http') +const path = require('path') +const fs = require('fs') const express = require('express') const cors = require('cors') const { WebSocketServer } = require('ws') @@ -8,6 +10,7 @@ const { getSituation } = require('./situationData') const app = express() const PORT = process.env.API_PORT || 3001 +app.set('trust proxy', 1) app.use(cors()) app.use(express.json()) app.use('/api', routes) @@ -17,6 +20,17 @@ app.post('/api/crawler/notify', (_, res) => { res.json({ ok: true }) }) +// 生产环境:提供前端静态文件 +const distPath = path.join(__dirname, '..', 'dist') +if (fs.existsSync(distPath)) { + app.use(express.static(distPath)) + app.get('*', (req, res, next) => { + if (!req.path.startsWith('/api') && req.path !== '/ws') { + res.sendFile(path.join(distPath, 'index.html')) + } else next() + }) +} + const server = http.createServer(app) const wss = new WebSocketServer({ server, path: '/ws' }) diff --git a/server/routes.js b/server/routes.js index 05c07a2..8119323 100644 --- a/server/routes.js +++ b/server/routes.js @@ -62,6 +62,46 @@ router.get('/situation', (req, res) => { } }) +// 来访统计:记录 IP,返回在看/累积 +function getClientIp(req) { + const forwarded = req.headers['x-forwarded-for'] + if (forwarded) return forwarded.split(',')[0].trim() + return req.ip || req.socket?.remoteAddress || 'unknown' +} + +router.post('/visit', (req, res) => { + try { + const ip = getClientIp(req) + db.prepare( + "INSERT OR REPLACE INTO visits (ip, last_seen) VALUES (?, datetime('now'))" + ).run(ip) + db.prepare( + 'INSERT INTO visitor_count (id, total) VALUES (1, 1) ON CONFLICT(id) DO UPDATE SET total = total + 1' + ).run() + const viewers = db.prepare( + "SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')" + ).get().n + const cumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0 + res.json({ viewers, cumulative }) + } catch (err) { + console.error(err) + res.status(500).json({ viewers: 0, cumulative: 0 }) + } +}) + +router.get('/stats', (req, res) => { + try { + const viewers = db.prepare( + "SELECT COUNT(*) as n FROM visits WHERE last_seen > datetime('now', '-2 minutes')" + ).get().n + const cumulative = db.prepare('SELECT total FROM visitor_count WHERE id = 1').get()?.total ?? 0 + res.json({ viewers, cumulative }) + } catch (err) { + console.error(err) + res.status(500).json({ viewers: 0, cumulative: 0 }) + } +}) + router.get('/events', (req, res) => { try { const s = getSituation() diff --git a/src/components/HeaderPanel.tsx b/src/components/HeaderPanel.tsx index 67e491a..6cdfda6 100644 --- a/src/components/HeaderPanel.tsx +++ b/src/components/HeaderPanel.tsx @@ -1,10 +1,19 @@ import { useState, useEffect } from 'react' -import { Link } from 'react-router-dom' import { StatCard } from './StatCard' import { useSituationStore } from '@/store/situationStore' import { useReplaySituation } from '@/hooks/useReplaySituation' import { usePlaybackStore } from '@/store/playbackStore' -import { Wifi, WifiOff, Clock, Database } from 'lucide-react' +import { Wifi, WifiOff, Clock, Share2, Heart, Eye } from 'lucide-react' + +const STORAGE_LIKES = 'us-iran-dashboard-likes' + +function getStoredLikes(): number { + try { + return parseInt(localStorage.getItem(STORAGE_LIKES) ?? '0', 10) + } catch { + return 0 + } +} export function HeaderPanel() { const situation = useReplaySituation() @@ -12,12 +21,64 @@ export function HeaderPanel() { const isReplayMode = usePlaybackStore((s) => s.isReplayMode) const { usForces, iranForces } = situation const [now, setNow] = useState(() => new Date()) + const [likes, setLikes] = useState(getStoredLikes) + const [liked, setLiked] = useState(false) + const [viewers, setViewers] = useState(0) + const [cumulative, setCumulative] = useState(0) useEffect(() => { const timer = setInterval(() => setNow(new Date()), 1000) return () => clearInterval(timer) }, []) + const fetchStats = async () => { + try { + const res = await fetch('/api/visit', { method: 'POST' }) + const data = await res.json() + if (data.viewers != null) setViewers(data.viewers) + if (data.cumulative != null) setCumulative(data.cumulative) + } catch { + setViewers((v) => (v > 0 ? v : 0)) + setCumulative((c) => (c > 0 ? c : 0)) + } + } + + useEffect(() => { + fetchStats() + const t = setInterval(fetchStats, 30000) + return () => clearInterval(t) + }, []) + + const handleShare = async () => { + const url = window.location.href + const title = '美伊军事态势显示' + if (typeof navigator.share === 'function') { + try { + await navigator.share({ title, url }) + } catch (e) { + if ((e as Error).name !== 'AbortError') { + await copyToClipboard(url) + } + } + } else { + await copyToClipboard(url) + } + } + + const copyToClipboard = (text: string) => { + return navigator.clipboard?.writeText(text) ?? Promise.resolve() + } + + const handleLike = () => { + if (liked) return + setLiked(true) + const next = likes + 1 + setLikes(next) + try { + localStorage.setItem(STORAGE_LIKES, String(next)) + } catch {} + } + const formatDateTime = (d: Date) => d.toLocaleString('zh-CN', { year: 'numeric', @@ -59,14 +120,33 @@ export function HeaderPanel() { )} -
- +
+ + 在看 {viewers} + | + 累积 {cumulative} +
+ + {isConnected ? ( <> diff --git a/src/components/WarMap.tsx b/src/components/WarMap.tsx index 46fdbdb..3d0dc9a 100644 --- a/src/components/WarMap.tsx +++ b/src/components/WarMap.tsx @@ -286,6 +286,8 @@ export function WarMap() { const tick = (t: number) => { const elapsed = t - startRef.current + const zoom = map.getZoom() + const zoomScale = Math.max(0.5, zoom / 4.2) // 随镜头缩放:放大变大、缩小变小(4.2 为默认 zoom) try { // 光点从起点飞向目标的循环动画 const src = map.getSource('attack-dots') as { setData: (d: GeoJSON.FeatureCollection) => void } | undefined @@ -307,11 +309,11 @@ export function WarMap() { const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.003) map.setPaintProperty('points-damaged', 'circle-opacity', blink) } - // attacked: 红色脉冲 2s 循环, 扩散半径 0→40px, opacity 1→0 (map.md) + // attacked: 红色脉冲 2s 循环, 半径随 zoom 缩放 if (map.getLayer('points-attacked-pulse')) { const cycle = 2000 const phase = (elapsed % cycle) / cycle - const r = 40 * phase + const r = 40 * phase * zoomScale const opacity = 1 - phase map.setPaintProperty('points-attacked-pulse', 'circle-radius', r) map.setPaintProperty('points-attacked-pulse', 'circle-opacity', opacity) @@ -376,11 +378,11 @@ export function WarMap() { ) israelSrc.setData({ type: 'FeatureCollection', features }) } - // 伊朗被打击目标:蓝色脉冲 (2s 周期) + // 伊朗被打击目标:蓝色脉冲 (2s 周期), 半径随 zoom 缩放 if (map.getLayer('allied-strike-targets-pulse')) { const cycle = 2000 const phase = (elapsed % cycle) / cycle - const r = 35 * phase + const r = 35 * phase * zoomScale const opacity = Math.max(0, 1 - phase * 1.2) map.setPaintProperty('allied-strike-targets-pulse', 'circle-radius', r) map.setPaintProperty('allied-strike-targets-pulse', 'circle-opacity', opacity) @@ -390,11 +392,11 @@ export function WarMap() { const blink = 0.5 + 0.5 * Math.sin(elapsed * 0.004) map.setPaintProperty('gdelt-events-orange', 'circle-opacity', blink) } - // GDELT 红色 (7–10):脉冲扩散 + // GDELT 红色 (7–10):脉冲扩散, 半径随 zoom 缩放 if (map.getLayer('gdelt-events-red-pulse')) { const cycle = 2200 const phase = (elapsed % cycle) / cycle - const r = 30 * phase + const r = 30 * phase * zoomScale const opacity = Math.max(0, 1 - phase * 1.1) map.setPaintProperty('gdelt-events-red-pulse', 'circle-radius', r) map.setPaintProperty('gdelt-events-red-pulse', 'circle-opacity', opacity)