- index.html manhuang.js 版本对齐内容 hash,避免面板吃到旧 JS - derive_shelf_events(limit=0) 返回空列表;key 查找兼容非 str - --record-outcome 同时 --fail/--dispute 只计一次 fail - build-tab-caches 价差与 shelf_events 共用一次差分
559 lines
18 KiB
Python
Executable file
559 lines
18 KiB
Python
Executable file
#!/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 侧手册路径(相对 HOME;Mac 常不可达)
|
||
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())
|