joy-cli/scripts/manhuang/build-tab-caches.py
feibisi af1e82e9a5 fix(manhuang): 采购雷达核查 — cache-bust、limit=0、回填计次
- index.html manhuang.js 版本对齐内容 hash,避免面板吃到旧 JS
- derive_shelf_events(limit=0) 返回空列表;key 查找兼容非 str
- --record-outcome 同时 --fail/--dispute 只计一次 fail
- build-tab-caches 价差与 shelf_events 共用一次差分
2026-07-13 14:01:54 +08:00

559 lines
18 KiB
Python
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 python3
"""蛮荒 tab 本地缓存契约:数据源新鲜度 / 密室脉冲 / 游侠手册索引 / 价差 / 货架事件 / 信任。
只写磁盘不打外网。dashboard collect 与 UI 只读本文件。
输出:~/.cache/manhuang/tab-caches.json
"""
from __future__ import annotations
import json
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# 保证可 import joy.dashboard.manhuang_shelf脚本直跑时
_ROOT = Path(__file__).resolve().parents[2]
_SRC = _ROOT / "src"
if _SRC.is_dir() and str(_SRC) not in sys.path:
sys.path.insert(0, str(_SRC))
from joy.dashboard.manhuang_shelf import ( # noqa: E402
derive_shelf_events,
enrich_shops_trust,
price_moves_from_events,
)
CACHE_ROOT = Path.home() / ".cache" / "manhuang"
OUT = CACHE_ROOT / "tab-caches.json"
SHOPS_PATHS = (
Path.home() / ".config" / "manhuang" / "shops.json",
Path.home() / "data" / "manhuang" / "shops.json",
)
QUOTES = CACHE_ROOT / "quotes" / "ai-accounts.json"
COMMUNITY = CACHE_ROOT / "quotes" / "community-prices.json"
HISTORY = CACHE_ROOT / "quotes" / "history"
DEMAND_LAST = CACHE_ROOT / "quotes" / ".demand-probe.last"
DEMAND_REQ = CACHE_ROOT / "quotes" / ".demand-probe.request"
OPEN_SUMMARY = CACHE_ROOT / "feeds" / "open-summary.json"
PULSE_BUNDLE = CACHE_ROOT / "live-pulse" / "bundle.json"
PULSE_STATE = CACHE_ROOT / "live-pulse" / "state.json"
CD_STATUS = CACHE_ROOT / "changedetection" / "status.json"
RELAYS = CACHE_ROOT / "relays" / "status.json"
GUISHI_STATUS = CACHE_ROOT / "guishi" / "status.json"
GUISHI_HB = CACHE_ROOT / "guishi" / "handbooks.json"
DASH_TMP = Path("/tmp/joy-dashboard.json")
# scout 侧手册路径(相对 HOMEMac 常不可达)
SCOUT_HANDBOOKS = (
"docs/intelligence/xianyu-data-trading-handbook.md",
"docs/intelligence/xianyu-hidden-market-handbook.md",
"data/reports/counter-intel/counter-intel-2026-03-14.md",
"docs/resource-pool-ops.md",
)
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _mtime(path: Path) -> float | None:
try:
return path.stat().st_mtime if path.is_file() else None
except OSError:
return None
def _age_s(mtime: float | None) -> float | None:
if mtime is None:
return None
return max(0.0, time.time() - mtime)
def _file_meta(path: Path, extra: dict[str, Any] | None = None) -> dict[str, Any]:
mt = _mtime(path)
out: dict[str, Any] = {
"path": str(path),
"mtime": mt,
"age_s": None if mt is None else round(_age_s(mt) or 0.0, 1),
"ok": bool(mt is not None),
}
if extra:
out.update(extra)
return out
def _load_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, UnicodeError):
return None
def _quotes_meta() -> dict[str, Any]:
skus_n = 0
market_n = 0
demand_n = 0
msrp_n = 0
data = _load_json(QUOTES) if QUOTES.is_file() else None
if isinstance(data, dict):
skus = data.get("skus")
if isinstance(skus, list):
skus_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_n += 1
elif best.get("kind") == "msrp" or (s.get("official_msrp") or {}).get("price") is not None:
msrp_n += 1
if s.get("demand_link") or s.get("shelf") == "demand":
demand_n += 1
return _file_meta(QUOTES, {
"skus_n": skus_n,
"market_n": market_n,
"msrp_n": msrp_n,
"demand_linked_n": demand_n,
"ts": (data or {}).get("ts") if isinstance(data, dict) else None,
})
def _demand_probe_meta() -> dict[str, Any]:
"""上次 demand-first 结果 + 是否仍有挂旗。"""
pending = DEMAND_REQ.is_file()
last = _load_json(DEMAND_LAST) if DEMAND_LAST.is_file() else None
out: dict[str, Any] = {
"pending": pending,
"ok": False,
"path_last": str(DEMAND_LAST),
"path_request": str(DEMAND_REQ),
}
if isinstance(last, dict):
out["ok"] = bool(last.get("ok"))
out["ts"] = last.get("ts")
out["epoch"] = last.get("epoch")
out["mode"] = last.get("mode")
out["reasons"] = last.get("reasons") or []
out["market_hits"] = last.get("market_hits")
out["probed_n"] = last.get("probed_n")
out["age_s"] = None
try:
if last.get("epoch"):
out["age_s"] = round(max(0.0, time.time() - float(last["epoch"])), 1)
except (TypeError, ValueError):
pass
if pending:
req = _load_json(DEMAND_REQ)
if isinstance(req, dict):
out["request_reasons"] = req.get("reasons") or []
out["request_ts"] = req.get("ts")
return out
def _live_pulse_meta() -> dict[str, Any]:
"""优先 bundle.json否则 state.json。"""
if PULSE_BUNDLE.is_file():
meta = _file_meta(PULSE_BUNDLE)
meta["source"] = "bundle"
return meta
if PULSE_STATE.is_file():
meta = _file_meta(PULSE_STATE)
meta["source"] = "state"
return meta
meta = _file_meta(PULSE_BUNDLE)
meta["source"] = "missing"
return meta
def _handbook_from_path(p: Path, home: Path) -> dict[str, Any] | None:
if not p.is_file():
return None
try:
text = p.read_text(encoding="utf-8", errors="ignore")
except OSError:
return None
lines = text.splitlines()
title = (lines[0].lstrip("# ").strip() if lines else p.name)[:120]
secs = [ln[3:].strip() for ln in lines if ln.startswith("## ")]
kind = "gray" if ("xianyu" in p.name or "闲鱼" in text[:200]) else "counter"
try:
rel = str(p.relative_to(home))
except ValueError:
rel = str(p)
return {
"title": title,
"path": rel,
"kind": kind,
"section_n": len(secs),
"sections": secs[:12],
}
def _handbooks_from_scout() -> list[dict[str, Any]]:
home = Path.home()
items: list[dict[str, Any]] = []
for rel in SCOUT_HANDBOOKS:
hb = _handbook_from_path(home / rel, home)
if hb:
items.append(hb)
return items
def _handbooks_from_dashboard() -> list[dict[str, Any]]:
data = _load_json(DASH_TMP)
if not isinstance(data, dict):
return []
mh = data.get("manhuang")
if not isinstance(mh, dict):
return []
recon = mh.get("recon") or {}
if not isinstance(recon, dict):
return []
raw = recon.get("handbooks") or []
if not isinstance(raw, list):
return []
out: list[dict[str, Any]] = []
for h in raw:
if not isinstance(h, dict):
continue
secs = h.get("sections") or []
if not isinstance(secs, list):
secs = []
out.append({
"title": (h.get("title") or "")[:120],
"path": h.get("path") or "",
"kind": h.get("kind") or "gray",
"section_n": int(h.get("section_n") or len(secs)),
"sections": [str(s) for s in secs[:12]],
})
return out
def _handbooks_index() -> dict[str, Any]:
items = _handbooks_from_scout()
source = "scout_local"
if not items:
items = _handbooks_from_dashboard()
source = "joy-dashboard.json" if items else "empty"
return {
"ok": bool(items),
"source": source,
"n": len(items),
"items": items,
}
def _history_day_files() -> list[Path]:
if not HISTORY.is_dir():
return []
files = sorted(HISTORY.glob("*.jsonl"))
return [p for p in files if p.is_file()]
def _last_jsonl_row(path: Path) -> dict[str, Any] | None:
try:
lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()]
except OSError:
return None
if not lines:
return None
try:
row = json.loads(lines[-1])
except json.JSONDecodeError:
return None
return row if isinstance(row, dict) else None
def _first_jsonl_row(path: Path) -> dict[str, Any] | None:
try:
with path.open(encoding="utf-8") as f:
for ln in f:
ln = ln.strip()
if not ln:
continue
try:
row = json.loads(ln)
except json.JSONDecodeError:
continue
return row if isinstance(row, dict) else None
except OSError:
return None
return None
def _best_map(row: dict[str, Any] | None) -> dict[str, Any]:
if not row:
return {}
best = row.get("best")
return best if isinstance(best, dict) else {}
def _history_pair() -> tuple[dict[str, Any], dict[str, Any], str | None]:
"""返回 (prev_best, cur_best, cur_ts)。无法对比时 best 为空 dict。"""
days = _history_day_files()
if not days:
return {}, {}, None
prev_row: dict[str, Any] | None = None
cur_row: dict[str, Any] | None = None
if len(days) >= 2:
prev_row = _last_jsonl_row(days[-2])
cur_row = _last_jsonl_row(days[-1])
else:
day = days[-1]
prev_row = _first_jsonl_row(day)
cur_row = _last_jsonl_row(day)
if prev_row is cur_row or (
prev_row and cur_row and prev_row.get("ts") == cur_row.get("ts")
):
return {}, {}, None
prev_best = _best_map(prev_row)
cur_best = _best_map(cur_row)
ts = None
if isinstance(cur_row, dict):
ts = cur_row.get("ts")
return prev_best, cur_best, ts if isinstance(ts, str) else None
def _shelf_events(limit: int = 40) -> list[dict[str, Any]]:
"""history 双快照 → 货架事件(价变 + 有货/售罄unknown≠sold_out"""
prev_best, cur_best, ts = _history_pair()
if not prev_best and not cur_best:
return []
return derive_shelf_events(prev_best, cur_best, ts=ts, limit=limit)
def _price_moves(limit: int = 20) -> list[dict[str, Any]]:
"""价差:优先从 shelf_events 抽取;否则直接差分(兼容)。"""
events = _shelf_events(limit=max(limit * 2, 40))
moves = price_moves_from_events(events, limit=limit)
if moves:
return moves
# 回退:仅价(无事件模块时的旧路径等价)
prev_best, cur_best, _ts = _history_pair()
if not prev_best or not cur_best:
return []
moves2: list[dict[str, Any]] = []
for sid, cur in cur_best.items():
if not isinstance(cur, dict):
continue
old = prev_best.get(sid)
if not isinstance(old, dict):
continue
try:
p_prev = old.get("p")
p_cur = cur.get("p")
if p_prev is None or p_cur is None:
continue
prev_f = float(p_prev)
cur_f = float(p_cur)
except (TypeError, ValueError):
continue
if prev_f == cur_f:
continue
delta = round(cur_f - prev_f, 4)
moves2.append({
"id": str(sid),
"prev": prev_f,
"cur": cur_f,
"ch": cur.get("ch") or old.get("ch") or "",
"delta": delta,
})
moves2.sort(key=lambda m: abs(float(m.get("delta") or 0)), reverse=True)
return moves2[:limit]
def _shops_trust_meta() -> dict[str, Any]:
"""读取本地 shops.json计算 trust 摘要(无外网)。"""
path = next((p for p in SHOPS_PATHS if p.is_file()), None)
if path is None:
return {"ok": False, "n": 0, "items": [], "path": None}
data = _load_json(path)
if not isinstance(data, dict):
return {"ok": False, "n": 0, "items": [], "path": str(path)}
raw = data.get("shops") if isinstance(data.get("shops"), list) else []
real: list[dict[str, Any]] = []
for s in raw:
if not isinstance(s, dict) or not s.get("id"):
continue
sid = str(s.get("id") or "")
if "example" in sid.lower() or "demo" in sid.lower():
continue
url = str(s.get("url") or "")
if ".invalid" in url:
continue
real.append(s)
enriched = enrich_shops_trust(real)
items = []
for s in enriched[:40]:
items.append({
"id": s.get("id"),
"name": s.get("name"),
"platform": s.get("platform") or s.get("kind"),
"risk": s.get("risk"),
"sku_for": s.get("sku_for") or [],
"trust_score": s.get("trust_score"),
"trust_band": s.get("trust_band"),
"trust": s.get("trust"),
"status": s.get("status"),
})
bands: dict[str, int] = {}
for it in items:
b = str(it.get("trust_band") or "unproven")
bands[b] = bands.get(b, 0) + 1
return {
"ok": True,
"path": str(path),
"n": len(items),
"bands": bands,
"items": items,
}
def _community_meta() -> dict[str, Any]:
data = _load_json(COMMUNITY) if COMMUNITY.is_file() else None
meta = _file_meta(COMMUNITY)
if isinstance(data, dict):
meta["ok"] = bool(data.get("ok"))
meta["overlay_n"] = data.get("overlay_n") or len(data.get("sku_overlay") or {})
meta["sources"] = {
k: bool((v or {}).get("ok")) if isinstance(v, dict) else False
for k, v in (data.get("sources") or {}).items()
}
meta["ts"] = data.get("ts")
return meta
def _changedetection_meta() -> dict[str, Any]:
data = _load_json(CD_STATUS) if CD_STATUS.is_file() else None
meta = _file_meta(CD_STATUS)
if isinstance(data, dict):
meta["ok"] = bool(data.get("ok"))
meta["mode"] = data.get("mode")
meta["counts"] = data.get("counts") or {}
meta["needs_api_key"] = data.get("needs_api_key")
meta["suggested_n"] = len(data.get("suggested_watches") or [])
meta["health_http"] = data.get("health_http")
meta["ts"] = data.get("ts")
return meta
def _relays_meta() -> dict[str, Any]:
data = _load_json(RELAYS) if RELAYS.is_file() else None
meta = _file_meta(RELAYS)
if isinstance(data, dict):
meta["ok"] = bool(data.get("ok"))
meta["counts"] = data.get("counts") or {}
meta["ts"] = data.get("ts")
return meta
def _guishi_meta() -> dict[str, Any]:
data = _load_json(GUISHI_STATUS) if GUISHI_STATUS.is_file() else None
meta = _file_meta(GUISHI_STATUS)
if isinstance(data, dict):
meta["ok"] = bool(data.get("ok"))
meta["counts"] = data.get("counts") or {}
meta["socks"] = (data.get("socks") or {}).get("ok")
meta["ts"] = data.get("ts")
meta["findings_n"] = len(data.get("findings") or [])
hb = _load_json(GUISHI_HB) if GUISHI_HB.is_file() else None
meta["handbooks_n"] = (hb or {}).get("n") if isinstance(hb, dict) else 0
return meta
def build(out_path: Path | None = None) -> dict[str, Any]:
path = out_path or OUT
# 只差分一次,价差从事件抽取(避免 _price_moves 再扫一遍 history
shelf = _shelf_events(40)
moves = price_moves_from_events(shelf, limit=20)
if not moves:
moves = _price_moves(20)
shops_t = _shops_trust_meta()
payload: dict[str, Any] = {
"ts": _now_iso(),
"ok": True,
"quotes": _quotes_meta(),
"community_prices": _community_meta(),
"changedetection": _changedetection_meta(),
"relays": _relays_meta(),
"guishi": _guishi_meta(),
"open_summary": _file_meta(OPEN_SUMMARY),
"live_pulse": _live_pulse_meta(),
"handbooks_index": _handbooks_index(),
"price_moves": moves,
"shelf_events": shelf,
"shops_trust": shops_t,
"demand_probe": _demand_probe_meta(),
"quote_api": {
"local": "http://127.0.0.1:8877/",
"note": "python3 scripts/manhuang/quote-aggregator.py",
},
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return payload
def main(argv: list[str] | None = None) -> int:
argv = list(argv if argv is not None else sys.argv[1:])
dry = "--dry" in argv or "--dry-run" in argv
if dry:
# 不写盘stdout 摘要
shelf = _shelf_events(40)
payload = {
"ts": _now_iso(),
"ok": True,
"quotes": _quotes_meta(),
"open_summary": _file_meta(OPEN_SUMMARY),
"live_pulse": _live_pulse_meta(),
"handbooks_index": _handbooks_index(),
"price_moves": _price_moves(20),
"shelf_events": shelf,
"shops_trust": _shops_trust_meta(),
"demand_probe": _demand_probe_meta(),
}
summary = {
"ts": payload["ts"],
"ok": True,
"quotes_skus_n": (payload["quotes"] or {}).get("skus_n"),
"quotes_age_s": (payload["quotes"] or {}).get("age_s"),
"open_summary_ok": (payload["open_summary"] or {}).get("ok"),
"live_pulse_ok": (payload["live_pulse"] or {}).get("ok"),
"handbooks_n": (payload["handbooks_index"] or {}).get("n"),
"price_moves_n": len(payload.get("price_moves") or []),
"shelf_events_n": len(payload.get("shelf_events") or []),
"shops_trust_n": (payload.get("shops_trust") or {}).get("n"),
}
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0
payload = build()
print(
json.dumps(
{
"ok": True,
"out": str(OUT),
"quotes_skus_n": (payload.get("quotes") or {}).get("skus_n"),
"handbooks_n": (payload.get("handbooks_index") or {}).get("n"),
"price_moves_n": len(payload.get("price_moves") or []),
"shelf_events_n": len(payload.get("shelf_events") or []),
"shops_trust_n": (payload.get("shops_trust") or {}).get("n"),
},
ensure_ascii=False,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())