Dew-OF-Aurora 1 hónapja
szülő
commit
808a6cf1b1
6 módosított fájl, 116 hozzáadás és 77 törlés
  1. 1 0
      .gitignore
  2. 9 5
      CLAUDE.md
  3. 24 7
      README.md
  4. 2 2
      config.local.json
  5. 35 42
      scripts/domain_updater.py
  6. 45 21
      workflow.md

+ 1 - 0
.gitignore

@@ -1,5 +1,6 @@
 runtime/
 cfip_runtime/
+local_runtime/
 .claude/
 cfst*/
 busybox*/

+ 9 - 5
CLAUDE.md

@@ -103,7 +103,10 @@ sudo bash scripts/uninstall_debian.sh --keep-auth-files
   - selected value JSON file
   - state file
   - export vars file
+- Builds mode-aligned `top_candidates` with unified semantic field names
+- Uses sparse JSON output: only writes fields that have values (no blank placeholder fields)
 - Supports fallback to the last good value from the configured state file
+- On error with fallback, writes `vars_file` with `STATUS: error_use_last_good` so downstream consumers stay updated
 
 ### 2) Server mode
 
@@ -111,13 +114,14 @@ sudo bash scripts/uninstall_debian.sh --keep-auth-files
 
 - API request settings
 - parser / record mapping / record filter
-- scoring and healthcheck behavior
+- scoring behavior
 - output paths under `runtime/`
 
 `scripts/run_update_and_commit.sh`:
 
 - Resolves output paths by calling `domain_updater.py --print-output-settings`
-- Runs the updater
+- Runs the updater and captures its JSON output
+- Detects `error_use_last_good` status and adjusts commit message prefix to `chore(fallback):`
 - Compares the selected value with `runtime-state`
 - Syncs configured repo-local output files into the target worktree
 - Commits and optionally pushes
@@ -143,6 +147,7 @@ There are two router-side entry styles:
 - Accepts either `domain` or `ip`
 - Rewrites VMess `server` for matched nodes
 - Uses `scriptResourceCache` with a short TTL
+- Cache key is scoped per `VALUE_SOURCE_MODE` to avoid cross-mode stale cache
 
 ## Configuration Model
 
@@ -157,7 +162,6 @@ Main blocks:
 - `record_filter`
 - `domain_filter`
 - `scoring`
-- `healthcheck`
 - `selection`
 - `output`
 - `notify`
@@ -169,7 +173,6 @@ Main blocks:
 - `source`
 - `cfst_local`
 - `domain_filter`
-- `healthcheck`
 - `selection`
 - `output`
 - `notify`
@@ -191,5 +194,6 @@ Main groups:
 - Server mode is the only mode intended to update `runtime-state`.
 - Router mode does not use git automation by default.
 - `runtime/` and `cfip_runtime/` are ignored on `main`; runtime artifacts are meant to be ephemeral locally.
-- Persistent `state.json` matters for fallback behavior in both modes.
+- Persistent `state.json` matters for fallback behavior in both modes (stores last good selected value).
+- `substore_vars.json` is a lightweight downstream contract (`AUTO_*`, `UPDATED_AT`, `STATUS`) for consumers that should not parse full runtime JSON.
 - Avoid reintroducing hardcoded assumptions about `runtime/current_domain.txt`; use config-driven paths instead.

+ 24 - 7
README.md

@@ -43,6 +43,17 @@
 - 服务器模式:`runtime/`
 - 路由器相关模式:`cfip_runtime/`
 
+`state.json` 与 `substore_vars.json` 用途:
+
+- `state.json`:持久化上次可用值(如 `last_good_domain` / `last_good_ip`),用于失败时 fallback。
+- `substore_vars.json`:给下游(如 Sub-Store)提供轻量变量(当前值、`UPDATED_AT`、`STATUS`),避免解析完整 runtime JSON。
+
+`top_candidates` 字段规则(API / CFST):
+
+- 仅保留语义重合或通用字段,字段名统一(如 `domain`、`ip`、`loss_rate`、`avg_latency`、`download_speed`、`region`)。
+- 改为“有值才输出该字段”,没有值不再写空字符串/空数组/`null` 占位。
+- API 评分相关字段(如 `score_value`、`scores`、`created_raw`)仅在有值时输出。
+
 ---
 
 ## 3. 服务器模式(API)
@@ -101,8 +112,11 @@ Git 提交信息中也会带 UTC 时间戳,例如:
 
 ```text
 chore: rotate preferred value to example.com (2026-05-11T13:03:16Z)
+chore(fallback): rotate preferred value to example.com (2026-05-11T13:03:16Z)
 ```
 
+`chore(fallback)` 表示源端出错、使用了上次的可用值。
+
 ---
 
 ## 4. 本地 Python `cfst` 模式
@@ -121,18 +135,18 @@ python3 scripts/domain_updater.py --config config.local.json
 
 默认配置当前指向:
 
-- `cfst_local.work_dir = ./cfst_linux_armv5`
+- `cfst_local.work_dir = ./cfst_darwin_arm64`(macOS ARM;部署时按实际平台修改,如 `./cfst_linux_armv5`
 - `cfst_local.binary = ./cfst`
-- 输出目录 `./cfip_runtime`
+- 输出目录 `config.local.json -> output.runtime_dir` 为准(当前仓库默认 `./runtime`
 
 ### 4.3 维护命令
 
 ```bash
-# 输出文件检查
-cat cfip_runtime/current_ip.txt
-cat cfip_runtime/current_ip.json
-cat cfip_runtime/state.json
-cat cfip_runtime/substore_vars.json
+# 输出文件检查(按当前 config.local.json 默认 runtime_dir=./runtime)
+cat runtime/current_ip.txt
+cat runtime/current_ip.json
+cat runtime/state.json
+cat runtime/substore_vars.json
 
 # 仅检查输出路径解析
 python3 scripts/domain_updater.py --config config.local.json --print-output-settings
@@ -273,6 +287,8 @@ curl http://127.0.0.1:8080/current_ip.txt
 - `ROUTER_MIN_SPEED_MB_PER_S = 5`
 - `download_speed` 纯数字按 `MB/s` 解释
 
+缓存按 `VALUE_SOURCE_MODE` 隔离,切换模式后不会命中旧缓存。
+
 ---
 
 ## 7. 路由器部署包打包
@@ -306,3 +322,4 @@ OUT_TAR="/path/to/vmess.tar.gz" \
 2. 只有服务器模式默认做 git 自动提交。
 3. `state.json` 需持久化,否则 fallback 不可用。
 4. 路由器模式请优先使用完整 BusyBox `httpd`,不要依赖系统精简 `httpd` / `nc`。
+5. 卸载脚本会自动清理对应服务用户的 `git safe.directory` 配置。

+ 2 - 2
config.local.json

@@ -35,7 +35,7 @@
     "top_n": 3
   },
   "output": {
-    "runtime_dir": "./cfip_runtime",
+    "runtime_dir": "./runtime",
     "selected_value_file": "current_ip.txt",
     "selected_value_json": "current_ip.json",
     "selected_value_json_key": "ip",
@@ -47,4 +47,4 @@
   "notify": {
     "command": ""
   }
-}
+}

+ 35 - 42
scripts/domain_updater.py

@@ -789,32 +789,25 @@ def choose_top_candidate_domains(filtered_domains, top_n, ranked_scored):
     return filtered_domains[:top_n]
 
 
-def blank_top_candidate(domain="", source_type=""):
-    return {
-        "domain": domain,
-        "ip": domain if IPV4_RE.match(domain) else "",
-        "source_type": source_type,
-        "sent": "",
-        "received": "",
-        "loss_rate": "",
-        "avg_latency": "",
-        "download_speed": "",
-        "region": "",
-        "location_country": "",
-        "location_city": "",
-        "host_provider": "",
-        "score_value": None,
-        "scores": [],
-        "created_raw": "",
-    }
-
-
 def text_or_blank(value):
     if value is None:
         return ""
     return str(value).strip()
 
 
+def set_if_nonempty_text(obj, key, value):
+    text = text_or_blank(value)
+    if text:
+        obj[key] = text
+
+
+def base_top_candidate(domain, source_type):
+    candidate = {"domain": domain, "source_type": source_type}
+    if IPV4_RE.match(domain):
+        candidate["ip"] = domain
+    return candidate
+
+
 def maybe_resolve_field(record, field_name, field_map):
     if not isinstance(record, dict):
         return None
@@ -825,36 +818,36 @@ def maybe_resolve_field(record, field_name, field_map):
 
 def build_cfst_candidate(row):
     domain = row.get("domain", "")
-    candidate = blank_top_candidate(domain=domain, source_type="cfst_local")
-    candidate["ip"] = text_or_blank(row.get("ip") or domain)
-    candidate["sent"] = text_or_blank(row.get("sent"))
-    candidate["received"] = text_or_blank(row.get("received"))
-    candidate["loss_rate"] = text_or_blank(row.get("loss_rate"))
-    candidate["avg_latency"] = text_or_blank(row.get("avg_latency"))
-    candidate["download_speed"] = text_or_blank(row.get("download_speed"))
-    candidate["region"] = text_or_blank(row.get("region"))
+    candidate = base_top_candidate(domain=domain, source_type="cfst_local")
+    set_if_nonempty_text(candidate, "ip", row.get("ip") or domain)
+    set_if_nonempty_text(candidate, "loss_rate", row.get("loss_rate"))
+    set_if_nonempty_text(candidate, "avg_latency", row.get("avg_latency"))
+    set_if_nonempty_text(candidate, "download_speed", row.get("download_speed"))
+    set_if_nonempty_text(candidate, "region", row.get("region"))
     return candidate
 
 
 def build_api_candidate(domain, record, field_map, scored_record=None):
-    candidate = blank_top_candidate(domain=domain, source_type="api")
-    candidate["ip"] = domain if IPV4_RE.match(domain) else ""
+    candidate = base_top_candidate(domain=domain, source_type="api")
 
     if record:
-        candidate["created_raw"] = text_or_blank(maybe_resolve_field(record, "created_at", field_map))
-        candidate["avg_latency"] = text_or_blank(maybe_resolve_field(record, "avg_latency", field_map))
-        candidate["loss_rate"] = text_or_blank(maybe_resolve_field(record, "avg_pkg_lost_rate", field_map))
-        candidate["location_country"] = text_or_blank(maybe_resolve_field(record, "location_country", field_map))
-        candidate["location_city"] = text_or_blank(maybe_resolve_field(record, "location_city", field_map))
-        candidate["host_provider"] = text_or_blank(maybe_resolve_field(record, "host_provider", field_map))
-        region_parts = [candidate["location_country"], candidate["location_city"]]
-        candidate["region"] = "/".join([x for x in region_parts if x])
+        set_if_nonempty_text(candidate, "created_raw", maybe_resolve_field(record, "created_at", field_map))
+        set_if_nonempty_text(candidate, "avg_latency", maybe_resolve_field(record, "avg_latency", field_map))
+        set_if_nonempty_text(candidate, "loss_rate", maybe_resolve_field(record, "avg_pkg_lost_rate", field_map))
+        set_if_nonempty_text(candidate, "download_speed", maybe_resolve_field(record, "download_speed", field_map))
+        location_country = text_or_blank(maybe_resolve_field(record, "location_country", field_map))
+        location_city = text_or_blank(maybe_resolve_field(record, "location_city", field_map))
+        region = "/".join([x for x in [location_country, location_city] if x])
+        set_if_nonempty_text(candidate, "region", region)
 
     if scored_record:
-        candidate["score_value"] = scored_record.get("score_value")
-        candidate["scores"] = list(scored_record.get("scores", []))
-        if not candidate["created_raw"]:
-            candidate["created_raw"] = text_or_blank(scored_record.get("created_raw"))
+        if scored_record.get("score_value") is not None:
+            candidate["score_value"] = scored_record.get("score_value")
+        scores = list(scored_record.get("scores", []))
+        if scores:
+            candidate["scores"] = scores
+        if "created_raw" not in candidate:
+            set_if_nonempty_text(candidate, "created_raw", scored_record.get("created_raw"))
 
     return candidate
 

+ 45 - 21
workflow.md

@@ -16,8 +16,8 @@ flowchart TD
       B5 --> B6[apply domain filter]
       B6 --> B7[apply record filter]
       B7 --> B8[score and rank]
-      B8 --> B9[optional healthcheck]
-      B9 --> B10[select preferred domain]
+      B8 --> B9[select preferred domain]
+      B9 --> B10[build top_candidates (sparse fields, unified semantics)]
       B10 --> B11[write runtime/current_domain.txt]
       B11 --> B12[write runtime/current_domain.json]
       B12 --> B13[write runtime/substore_vars.json]
@@ -26,13 +26,15 @@ flowchart TD
 
     A2 --> C1[resolve configured output paths]
     C1 --> A4
-    A4 --> C2[read selected text file from resolved path]
-    C2 --> C3[compare with runtime-state HEAD]
-    C3 --> C4{changed?}
-    C4 -- no --> C5[skip commit/push]
-    C4 -- yes --> C6[sync configured repo-local output files]
-    C6 --> C7[commit on runtime-state]
-    C7 --> C8[optional push]
+    A4 --> C2[capture updater JSON output]
+    C2 --> C3[detect status: error_use_last_good?]
+    C3 --> C4[read selected text file from resolved path]
+    C4 --> C5[compare with runtime-state HEAD]
+    C5 --> C6{changed?}
+    C6 -- no --> C7[skip commit/push]
+    C6 -- yes --> C8[sync configured repo-local output files]
+    C8 --> C9["commit on runtime-state<br/>(chore: or chore(fallback):)"]
+    C9 --> C10[optional push]
 ```
 
 ## 2. Local Python cfst Mode
@@ -46,12 +48,13 @@ flowchart TD
       B1[read config.local.json] --> B2[resolve output paths]
       B2 --> B3[run local cfst]
       B3 --> B4[parse result.csv]
-      B4 --> B5[optional filter / optional healthcheck]
+      B4 --> B5[apply domain filter]
       B5 --> B6[select preferred ip]
-      B6 --> B7[write cfip_runtime/current_ip.txt]
-      B7 --> B8[write cfip_runtime/current_ip.json]
-      B8 --> B9[write cfip_runtime/substore_vars.json]
-      B9 --> B10[write cfip_runtime/state.json]
+      B6 --> B7[build top_candidates (sparse fields, unified semantics)]
+      B7 --> B8[write configured current_ip.txt]
+      B8 --> B9[write configured current_ip.json]
+      B9 --> B10[write configured substore_vars.json]
+      B10 --> B11[write configured state.json]
     end
 ```
 
@@ -67,10 +70,10 @@ flowchart TD
       B1[read config_router.conf] --> B2[run cfst]
       B2 --> B3[read result.csv]
       B3 --> B4[pick best ip]
-      B4 --> B5[write cfip_runtime/current_ip.txt]
-      B5 --> B6[write cfip_runtime/current_ip.json]
-      B6 --> B7[write cfip_runtime/substore_vars.json]
-      B7 --> B8[write cfip_runtime/state.json]
+      B4 --> B5[write configured current_ip.txt]
+      B5 --> B6[write configured current_ip.json]
+      B6 --> B7[write configured substore_vars.json]
+      B7 --> B8[write configured state.json]
     end
 
     subgraph R2["router_local_http.sh"]
@@ -83,12 +86,33 @@ flowchart TD
     end
 ```
 
-## 4. Consumers
+## 4. Error / Fallback Flow
+
+```mermaid
+flowchart TD
+    A1[updater encounters error] --> A2{last_good value exists?}
+    A2 -- no --> A3[exit with error]
+    A2 -- yes --> A4[write last_good to selected text file]
+    A4 --> A5["write selected JSON<br/>(status: error_use_last_good)"]
+    A5 --> A6["write vars file<br/>(STATUS: error_use_last_good)"]
+    A6 --> A7[write state.json with error]
+    A7 --> A8[run notify command]
+    A8 --> A9["output JSON to stdout<br/>(status: error_use_last_good)"]
+    A9 --> A10["run_update_and_commit.sh detects fallback status"]
+    A10 --> A11["commit with chore(fallback): prefix"]
+```
+
+## 5. Consumers
 
 ```mermaid
 flowchart TD
     A1[runtime/current_domain.json] --> B1[Sub-Store operator]
     A2[cfip_runtime/current_ip.json over LAN] --> B1
-    B1 --> C1[read domain or ip]
-    C1 --> C2[rewrite matched proxy server]
+    B1 --> C1["cache lookup<br/>(key scoped per VALUE_SOURCE_MODE)"]
+    C1 --> C2{cache hit?}
+    C2 -- yes --> C3[use cached value]
+    C2 -- no --> C4[fetch and decide by mode]
+    C4 --> C5[read domain or ip]
+    C3 --> C6[rewrite matched proxy server]
+    C5 --> C6
 ```