actions-to-board 消费 chain_v2 breaks;refresh-chain-breaks 本地落盘; 支付轨识别 pay_msrp_only(排除 sms 假 pay);碎银 tab 与 clashctl board 入口。
348 lines
13 KiB
Bash
Executable file
348 lines
13 KiB
Bash
Executable file
#!/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 N0–N5;每次 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 价源 + 5sim(awesome-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 :9050),Mac 不跑 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 → konqi(urlscan 自有域默认不推;节流 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 失败 → 非 0;quotes 成功但尾部有 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"
|