joy-cli/scripts/manhuang/run-quote-probe.sh
feibisi fd715ff159 feat(manhuang): chain breaks 进 Board + 支付轨市价/MSRP 质检
actions-to-board 消费 chain_v2 breaks;refresh-chain-breaks 本地落盘;
支付轨识别 pay_msrp_only(排除 sms 假 pay);碎银 tab 与 clashctl board 入口。
2026-07-13 09:06:07 +08:00

348 lines
13 KiB
Bash
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# 蛮荒比价探针:定时拉外网 → 本地缓存(自己渠道)
# 输出:~/.cache/manhuang/quotes/ai-accounts.json
# 历史:~/.cache/manhuang/quotes/history/YYYYMMDD.jsonl
# 请求旗:~/.cache/manhuang/quotes/.demand-probe.request
# collect 在链危机/需求货架无实价时写入;本脚本消费后删除并写 .demand-probe.last
# E/F即使 quotes 新鲜 skip仍跑 named-relays + notify-chain
#
# 用法:
# bash run-quote-probe.sh # 智能:有 request → demand-first否则 quotes 过期才 rotate
# bash run-quote-probe.sh --force # 强制 rotate 半盘+demand
# bash run-quote-probe.sh --demand-first # 仅 demand SKU
# bash run-quote-probe.sh --rotate --force
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
PROBE="$ROOT/scripts/manhuang/probe-ai-account-quotes.py"
CACHE_DIR="${HOME}/.cache/manhuang/quotes"
QUOTES="${CACHE_DIR}/ai-accounts.json"
REQ="${CACHE_DIR}/.demand-probe.request"
LAST="${CACHE_DIR}/.demand-probe.last"
LOG_DIR="${HOME}/logs"
LOG="${LOG_DIR}/manhuang-quotes.log"
LOCK="${CACHE_DIR}/.probe.lock"
# quotes 超过该秒数才跑常规 rotate默认 ~3.5h
QUOTES_MAX_AGE_S="${QUOTES_MAX_AGE_S:-12600}"
mkdir -p "$CACHE_DIR" "$LOG_DIR"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; }
quotes_age_s() {
if [[ ! -f "$QUOTES" ]]; then
echo 999999
return
fi
# mtime age
local now mt
now=$(date +%s)
mt=$(stat -f %m "$QUOTES" 2>/dev/null || stat -c %Y "$QUOTES" 2>/dev/null || echo 0)
echo $((now - mt))
}
# 简单锁,防 launchd 重叠
if [[ -f "$LOCK" ]]; then
oldpid="$(cat "$LOCK" 2>/dev/null || true)"
if [[ -n "${oldpid:-}" ]] && kill -0 "$oldpid" 2>/dev/null; then
log "skip: another probe running pid=$oldpid"
exit 0
fi
fi
echo $$ >"$LOCK"
trap 'rm -f "$LOCK"' EXIT
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}"
export PYTHONPATH="${ROOT}/src${PYTHONPATH:+:$PYTHONPATH}"
# 可选密钥CD / live-pulse— 不存在则跳过
KEYS_ENV="${HOME}/.config/manhuang/keys.env"
if [[ -f "$KEYS_ENV" ]]; then
set -a
# shellcheck disable=SC1090
source "$KEYS_ENV" 2>/dev/null || true
set +a
fi
ARGS=("$@")
MODE="explicit"
SKIP_QUOTES=0
# 无参数:智能调度
if [[ ${#ARGS[@]} -eq 0 ]]; then
if [[ -f "$REQ" ]]; then
ARGS=(--demand-first --force)
MODE="demand-request"
log "honor request: $REQ ($(head -c 200 "$REQ" | tr '\n' ' '))"
else
age=$(quotes_age_s)
if [[ "$age" -ge "$QUOTES_MAX_AGE_S" ]]; then
ARGS=(--rotate --force)
MODE="rotate-stale"
log "quotes age ${age}s >= ${QUOTES_MAX_AGE_S}s → rotate"
else
MODE="quotes-fresh"
SKIP_QUOTES=1
log "skip quotes: age ${age}s < ${QUOTES_MAX_AGE_S}s (仍跑 relays+notify)"
fi
fi
fi
rc=0
STAGE_FAIL=0
stage_fail() { STAGE_FAIL=$((STAGE_FAIL + 1)); log "stage_fail: $*"; }
if [[ "$SKIP_QUOTES" -eq 0 ]]; then
log "start mode=$MODE: python3 $PROBE ${ARGS[*]}"
set +e
python3 "$PROBE" "${ARGS[@]}" >>"$LOG" 2>&1
rc=$?
set -e
[[ "$rc" -ne 0 ]] && stage_fail "quotes rc=$rc"
# 成功消费 request / 任意 demand-first写 last 含实价命中数
if [[ "$rc" -eq 0 ]]; then
if [[ -f "$REQ" ]] || [[ "$MODE" == "demand-request" ]] || printf '%s\n' "${ARGS[@]}" | grep -q -- '--demand-first'; then
now=$(date +%s)
python3 - <<PY
import json, time
from pathlib import Path
quotes = Path("${QUOTES}")
req_p = Path("${REQ}")
last_p = Path("${LAST}")
reasons = []
if req_p.is_file():
try:
reasons = json.loads(req_p.read_text()).get("reasons") or []
except Exception:
pass
market_hits = 0
probed_n = 0
msrp_n = 0
if quotes.is_file():
try:
data = json.loads(quotes.read_text())
skus = data.get("skus") or []
probed_n = len(skus)
for s in skus:
if not isinstance(s, dict):
continue
best = s.get("best") or {}
bm = s.get("best_market") or {}
if bm.get("price") is not None or best.get("kind") == "marketplace":
market_hits += 1
elif best.get("kind") == "msrp":
msrp_n += 1
except Exception:
pass
row = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"epoch": ${now},
"ok": True,
"mode": "${MODE}",
"reasons": reasons,
"market_hits": market_hits,
"msrp_n": msrp_n,
"probed_n": probed_n,
}
last_p.write_text(json.dumps(row, ensure_ascii=False, indent=2) + "\n")
print("last", row)
PY
rm -f "$REQ"
log "cleared request; wrote $LAST (market_hits in last)"
fi
fi
# 探针后刷新 tab 缓存契约
if [[ -f "$ROOT/scripts/manhuang/build-tab-caches.py" ]]; then
python3 "$ROOT/scripts/manhuang/build-tab-caches.py" >>"$LOG" 2>&1 || stage_fail "build-tab-caches(early)"
fi
fi
# N: 网络供应链(机场/Clash N0N5每次 launchd 都跑)
if [[ -f "$ROOT/scripts/manhuang/probe-proxy-path.py" ]]; then
# quotes-fresh 时用轻量;强制/报价轮用 --deep
if [[ "$SKIP_QUOTES" -eq 1 ]]; then
python3 "$ROOT/scripts/manhuang/probe-proxy-path.py" >>"$LOG" 2>&1 || stage_fail "net-path"
else
python3 "$ROOT/scripts/manhuang/probe-proxy-path.py" --deep >>"$LOG" 2>&1 || stage_fail "net-path"
fi
fi
# E: 具名中转存活(轻量,每次 launchd 都跑,含 quotes-fresh
if [[ -f "$ROOT/scripts/manhuang/probe-named-relays.py" ]]; then
python3 "$ROOT/scripts/manhuang/probe-named-relays.py" >>"$LOG" 2>&1 || stage_fail "relays"
fi
# 社区/GitHub 价源 + 5simawesome-ai-api-proxy / OpenRouter / 接码)
if [[ -f "$ROOT/scripts/manhuang/probe-community-prices.py" ]]; then
python3 "$ROOT/scripts/manhuang/probe-community-prices.py" >>"$LOG" 2>&1 || stage_fail "community-prices"
# 有 community 后若刚跑过 quotes再轻量 merge读旧缓存写 community 字段
if [[ -f "$QUOTES" && -f "$ROOT/scripts/manhuang/probe-ai-account-quotes.py" ]]; then
python3 - <<'PY' >>"$LOG" 2>&1 || true
import json
from pathlib import Path
import importlib.util
root = Path(__file__).resolve().parent if False else Path.home()
# inline merge using community file + quotes
qpath = Path.home() / ".cache/manhuang/quotes/ai-accounts.json"
cpath = Path.home() / ".cache/manhuang/quotes/community-prices.json"
if not qpath.is_file() or not cpath.is_file():
raise SystemExit(0)
q = json.loads(qpath.read_text())
c = json.loads(cpath.read_text())
ov = c.get("sku_overlay") or {}
changed = 0
for s in q.get("skus") or []:
if not isinstance(s, dict):
continue
o = ov.get(s.get("id") or "")
if not isinstance(o, dict):
continue
s["community"] = o
kind = o.get("kind") or ""
if kind == "marketplace" and not (s.get("best_market") or {}).get("price") and o.get("price") is not None:
s["best_market"] = {
"channel": o.get("channel"), "price": o.get("price"),
"currency": o.get("currency") or "USD", "url": o.get("url"),
"kind": kind, "unit": o.get("unit"), "source": "community",
}
changed += 1
if kind == "relay_index":
s["relay_index"] = o
changed += 1
qpath.write_text(json.dumps(q, ensure_ascii=False, indent=2) + "\n")
print("community merge into quotes changed_fields~", changed)
PY
fi
fi
# ChangeDetection 状态ai-team有 CHANGEDTECTION_API_KEY 时拉 watches
if [[ -f "$ROOT/scripts/manhuang/probe-changedetection.py" ]]; then
python3 "$ROOT/scripts/manhuang/probe-changedetection.py" >>"$LOG" 2>&1 || stage_fail "changedetection"
fi
# 鬼市探针在 **scout**Tor :9050Mac 不跑 onion结果 scp 镜像到本机缓存便于离线看
GUISHI_OK=0
if command -v ssh >/dev/null 2>&1; then
if ssh -o BatchMode=yes -o ConnectTimeout=12 scout \
'python3 ~/scripts/manhuang/probe-guishi.py; python3 ~/scripts/manhuang/build-guishi-handbooks.py || true' \
>>"$LOG" 2>&1; then
mkdir -p "${HOME}/.cache/manhuang/guishi"
if scp -o BatchMode=yes -o ConnectTimeout=12 \
scout:data/manhuang/guishi/status.json \
scout:data/manhuang/guishi/handbooks.json \
"${HOME}/.cache/manhuang/guishi/" >>"$LOG" 2>&1; then
GUISHI_OK=1
log "scout guishi probe ok + mirrored"
else
stage_fail "scp guishi cache"
fi
else
stage_fail "scout guishi probe"
log "scout guishi probe failed (cron still runs on scout)"
fi
else
stage_fail "no ssh for guishi"
log "skip guishi: no ssh (expect scout cron)"
fi
# ChangeDetection补给 shops.json + 鬼市浅水 watch幂等 skip 已有)
if [[ -f "$ROOT/scripts/manhuang/ensure-cd-watches.py" ]]; then
python3 "$ROOT/scripts/manhuang/ensure-cd-watches.py" --all-guishi >>"$LOG" 2>&1 \
|| stage_fail "ensure-cd-watches"
fi
# 刷新 tab-caches含 community/CD/relays meta
if [[ -f "$ROOT/scripts/manhuang/build-tab-caches.py" ]]; then
python3 "$ROOT/scripts/manhuang/build-tab-caches.py" >>"$LOG" 2>&1 || stage_fail "build-tab-caches"
fi
# F: 链危机/价差/中转 down → 通知 konqi节流在脚本内
if [[ -f "$ROOT/scripts/manhuang/notify-chain.sh" ]]; then
bash "$ROOT/scripts/manhuang/notify-chain.sh" >>"$LOG" 2>&1 || stage_fail "notify-chain"
fi
# 可行动 actions → Board observe节流失败不阻断管线
# chain_v2 breaks 缓存(本地)→ Board
if [[ -f "$ROOT/scripts/manhuang/refresh-chain-breaks.py" ]]; then
log "stage: chain-breaks"
PYTHONPATH="${ROOT}/src${PYTHONPATH:+:$PYTHONPATH}" \
python3 "$ROOT/scripts/manhuang/refresh-chain-breaks.py" >>"$LOG" 2>&1 || stage_fail "chain-breaks"
fi
if [[ -f "$ROOT/scripts/manhuang/actions-to-board.sh" ]]; then
bash "$ROOT/scripts/manhuang/actions-to-board.sh" >>"$LOG" 2>&1 || stage_fail "actions-to-board"
fi
# 鬼市品牌 THREAT HIT → konqiurlscan 自有域默认不推;节流 4h
if [[ -f "$ROOT/scripts/manhuang/notify-guishi-brand.sh" ]]; then
bash "$ROOT/scripts/manhuang/notify-guishi-brand.sh" >>"$LOG" 2>&1 || stage_fail "notify-guishi-brand"
fi
# 管线健康快照UI/排障stage_fail 计数可见)
export MANHUANG_STAGE_FAIL="$STAGE_FAIL"
export MANHUANG_QUOTES_RC="$rc"
export MANHUANG_GUISHI_OK="$GUISHI_OK"
python3 - <<'PY' >>"$LOG" 2>&1 || true
import json, os, time
from pathlib import Path
home = Path.home()
cache = home / ".cache" / "manhuang"
def meta(p):
p = Path(p)
if not p.is_file():
return {"ok": False, "path": str(p)}
st = p.stat()
return {"ok": True, "path": str(p), "bytes": st.st_size, "age_s": round(time.time() - st.st_mtime, 1)}
stages = {
"quotes": meta(cache / "quotes" / "ai-accounts.json"),
"community": meta(cache / "quotes" / "community-prices.json"),
"relays": meta(cache / "relays" / "status.json"),
"changedetection": meta(cache / "changedetection" / "status.json"),
"guishi": meta(cache / "guishi" / "status.json"),
"handbooks": meta(cache / "guishi" / "handbooks.json"),
"tab_caches": meta(cache / "tab-caches.json"),
"shops": meta(home / ".config" / "manhuang" / "shops.json"),
}
g = stages["guishi"]
if g.get("ok"):
try:
d = json.loads(Path(g["path"]).read_text())
g["host"] = d.get("host")
g["checks"] = (d.get("counts") or {}).get("checks")
g["ok_n"] = (d.get("counts") or {}).get("ok")
g["fail_n"] = (d.get("counts") or {}).get("fail")
g["circuit_ok"] = (d.get("socks") or {}).get("circuit_ok")
g["socks_ok"] = (d.get("socks") or {}).get("ok")
g["brand_hits"] = (d.get("counts") or {}).get("brand_hits")
g["brand_threat"] = (d.get("counts") or {}).get("brand_threat")
except Exception as e:
g["parse_error"] = str(e)[:80]
stage_fail = int(os.environ.get("MANHUANG_STAGE_FAIL") or 0)
quotes_rc = int(os.environ.get("MANHUANG_QUOTES_RC") or 0)
core_ok = all(stages[k].get("ok") for k in ("quotes", "guishi", "tab_caches"))
out = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"ok": core_ok and stage_fail == 0 and quotes_rc == 0,
"core_ok": core_ok,
"stage_fail_n": stage_fail,
"quotes_rc": quotes_rc,
"guishi_ssh_ok": os.environ.get("MANHUANG_GUISHI_OK") == "1",
"stages": stages,
"tor_hint": (
"circuit_ok" if g.get("circuit_ok")
else ("socks_only" if g.get("socks_ok") else "tor_down")
),
}
(cache / "pipeline-last.json").write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n")
print("pipeline-last ok=", out.get("ok"), "stage_fail=", stage_fail, "tor=", out.get("tor_hint"))
PY
# quotes 失败 → 非 0quotes 成功但尾部有 stage_fail → 2可观测不伪装全绿
final_rc=$rc
if [[ "$rc" -eq 0 && "$STAGE_FAIL" -gt 0 ]]; then
final_rc=2
log "warn: quotes ok but stage_fail=$STAGE_FAIL → exit 2"
fi
log "done rc=$final_rc quotes_rc=$rc stage_fail=$STAGE_FAIL mode=$MODE quotes=$(wc -c <"$QUOTES" 2>/dev/null || echo 0)B"
exit "$final_rc"