docs: 联网搜索服务架构方案全套(plan-final/design-arch/选型决策/整合导览/MCP文档/部署预设/联调手册) bench: 5 方案 + 代理 + 站点矩阵本机实测工程(无密钥) 部署目标:primary mgr1 先行测试(待批准后执行)
275 lines
8.4 KiB
Python
275 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
||
"""Build lightpanda/results.json from raw artifacts. No secrets."""
|
||
import json, math, os, statistics
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent
|
||
RAW = ROOT / "raw"
|
||
SCHEME = "chrome-headless-shell"
|
||
|
||
|
||
def percentile(xs, p):
|
||
if not xs:
|
||
return None
|
||
s = sorted(xs)
|
||
if len(s) == 1:
|
||
return round(s[0], 4)
|
||
k = (len(s) - 1) * p / 100.0
|
||
f = math.floor(k)
|
||
c = math.ceil(k)
|
||
if f == c:
|
||
return round(s[int(k)], 4)
|
||
return round(s[f] + (s[c] - s[f]) * (k - f), 4)
|
||
|
||
|
||
def load_jsonl(p):
|
||
rows = []
|
||
if not p.exists():
|
||
return rows
|
||
for line in p.read_text(encoding="utf-8").splitlines():
|
||
line = line.strip()
|
||
if line:
|
||
rows.append(json.loads(line))
|
||
return rows
|
||
|
||
|
||
def tsv_mibs(p):
|
||
vals = []
|
||
if not p.exists():
|
||
return vals
|
||
lines = p.read_text(encoding="utf-8").splitlines()
|
||
if len(lines) < 2:
|
||
return vals
|
||
hdr = lines[0].split("\t")
|
||
i = hdr.index("mem_mib") if "mem_mib" in hdr else 4
|
||
for line in lines[1:]:
|
||
parts = line.split("\t")
|
||
if len(parts) <= i:
|
||
continue
|
||
try:
|
||
v = float(parts[i])
|
||
except ValueError:
|
||
continue
|
||
if v > 0:
|
||
vals.append(v)
|
||
return vals
|
||
|
||
|
||
def median(xs):
|
||
return round(statistics.median(xs), 3) if xs else None
|
||
|
||
|
||
def pmax(xs):
|
||
return round(max(xs), 3) if xs else None
|
||
|
||
|
||
def mem_score(idle, delta, burst):
|
||
def band(v, cuts):
|
||
if v is None:
|
||
return None
|
||
for score, lim in cuts:
|
||
if v <= lim:
|
||
return score
|
||
return 0
|
||
parts = [
|
||
band(idle, [(100, 60), (80, 100), (60, 200), (40, 400)]),
|
||
band(delta, [(100, 30), (80, 80), (60, 200), (40, 350)]),
|
||
band(burst, [(100, 200), (80, 400), (60, 700), (40, 1000)]),
|
||
]
|
||
parts = [x for x in parts if x is not None]
|
||
return round(sum(parts) / 3, 1) if parts else None
|
||
|
||
|
||
def quality_js(t4):
|
||
ctrl = [r for r in t4 if r.get("template") == "T4-ctrl"]
|
||
shop = [r for r in t4 if r.get("template") == "T4-shop"]
|
||
if any(r.get("assert_ok") for r in ctrl):
|
||
if any(r.get("assert_ok") and not r.get("note") == "shop_fields_missing" for r in shop) or any(
|
||
r.get("assert_ok") for r in shop
|
||
):
|
||
return 100
|
||
# ctrl ok, shop fields missing but text present
|
||
if shop and any((r.get("text_len") or 0) > 200 for r in shop):
|
||
return 70
|
||
return 70
|
||
if any((r.get("text_len") or 0) > 100 for r in t4):
|
||
return 40
|
||
return 0
|
||
|
||
|
||
def stab_score(rate, oom):
|
||
if oom:
|
||
return 0
|
||
if rate is None:
|
||
return None
|
||
if rate >= 0.99:
|
||
return 100
|
||
if rate >= 0.95:
|
||
return 80
|
||
if rate >= 0.85:
|
||
return 60
|
||
if rate >= 0.70:
|
||
return 40
|
||
return 0
|
||
|
||
|
||
def lat_t4(p95):
|
||
if p95 is None:
|
||
return None
|
||
if p95 <= 5:
|
||
return 100
|
||
if p95 <= 12:
|
||
return 70
|
||
if p95 <= 25:
|
||
return 40
|
||
return 0
|
||
|
||
|
||
def lat_t5(t5_p95, t4_p95):
|
||
if t5_p95 is None:
|
||
return None
|
||
if t4_p95 and t4_p95 > 0:
|
||
ratio = t5_p95 / t4_p95
|
||
if ratio <= 3:
|
||
return 100
|
||
if ratio <= 5:
|
||
return 70
|
||
if ratio <= 8:
|
||
return 40
|
||
return 0
|
||
return 40 if t5_p95 <= 25 else 0
|
||
|
||
|
||
def main():
|
||
t4 = load_jsonl(RAW / "t4.jsonl")
|
||
t5 = load_jsonl(RAW / "t5.jsonl")
|
||
idle = tsv_mibs(RAW / "idle.tsv")
|
||
sess = tsv_mibs(RAW / "session.tsv")
|
||
burst = tsv_mibs(RAW / "t5-stats.tsv")
|
||
meta = {}
|
||
if (RAW / "image-meta.json").exists():
|
||
meta = json.loads((RAW / "image-meta.json").read_text(encoding="utf-8"))
|
||
|
||
idle_p50 = median(idle)
|
||
sess_peak = pmax(sess)
|
||
burst_peak = pmax(burst)
|
||
delta = None
|
||
if idle_p50 is not None and sess_peak is not None:
|
||
delta = round(max(0.0, sess_peak - idle_p50), 3)
|
||
|
||
def pack(rows, key):
|
||
sub = [r for r in rows if r.get("template") == key]
|
||
times = [r["t_total"] for r in sub if r.get("t_total") is not None]
|
||
return {
|
||
"n": len(sub),
|
||
"success": sum(1 for r in sub if r.get("assert_ok")),
|
||
"http_codes": [r.get("http_code") for r in sub],
|
||
"p50_s": percentile(times, 50),
|
||
"p95_s": percentile(times, 95),
|
||
"assert_ok": any(r.get("assert_ok") for r in sub),
|
||
"note": "; ".join(sorted({r.get("note") for r in sub if r.get("note")})),
|
||
"html_vs_text": [
|
||
{"html_len": r.get("html_len"), "text_len": r.get("text_len"), "title": r.get("title")}
|
||
for r in sub[:3]
|
||
],
|
||
}
|
||
|
||
t4_ctrl_times = [r["t_total"] for r in t4 if r.get("template") == "T4-ctrl" and r.get("t_total") is not None]
|
||
t5_ok = [r for r in t5 if r.get("assert_ok")]
|
||
t5_times = [r["t_total"] for r in t5 if r.get("t_total") is not None]
|
||
t5_n = len(t5)
|
||
t5_rate = (len(t5_ok) / t5_n) if t5_n else None
|
||
# T5 assertion uses T4-ctrl quality; scheme stability also considers T4-ctrl
|
||
ctrl = [r for r in t4 if r.get("template") == "T4-ctrl"]
|
||
ctrl_rate = (sum(1 for r in ctrl if r.get("assert_ok")) / len(ctrl)) if ctrl else None
|
||
combined_rate = t5_rate if t5_rate is not None else ctrl_rate
|
||
|
||
t4_waf = []
|
||
for r in t4:
|
||
t4_waf.append({
|
||
"tier": r.get("template"),
|
||
"url": r.get("url"),
|
||
"blocked": r.get("blocked"),
|
||
"vendor": r.get("challenge_vendor"),
|
||
"http_status_in_page": r.get("http_status_in_page"),
|
||
"final_url": r.get("final_url"),
|
||
"title": r.get("title"),
|
||
})
|
||
|
||
oom = bool(meta.get("oom"))
|
||
q = quality_js(t4)
|
||
t4_p95 = percentile(t4_ctrl_times, 95)
|
||
t5_p95 = percentile(t5_times, 95)
|
||
l4 = lat_t4(t4_p95)
|
||
l5 = lat_t5(t5_p95, t4_p95)
|
||
lat_parts = [x for x in (l4, l5) if x is not None]
|
||
lat = round(sum(lat_parts) / len(lat_parts), 1) if lat_parts else None
|
||
mem = mem_score(idle_p50, delta, burst_peak)
|
||
stab = stab_score(combined_rate, oom)
|
||
ops = 70 if SCHEME == "lightpanda" else 55
|
||
total = None
|
||
if None not in (mem, q, stab, lat, ops):
|
||
total = round(mem * 0.30 + q * 0.25 + stab * 0.20 + lat * 0.15 + ops * 0.10, 1)
|
||
|
||
queue = meta.get("queue") or {}
|
||
out = {
|
||
"scheme": SCHEME,
|
||
"group": "foreign_proxy",
|
||
"image": {
|
||
"ref": meta.get("ref"),
|
||
"digest": meta.get("digest"),
|
||
"id": meta.get("id"),
|
||
"arch": meta.get("arch"),
|
||
"pull": meta.get("pull"),
|
||
},
|
||
"probed_at": meta.get("probed_at"),
|
||
"commands": meta.get("commands") or [],
|
||
"proxy": meta.get("proxy") or {},
|
||
"rss_source": "docker_stats+cgroup",
|
||
"idle_mb_p50": idle_p50,
|
||
"session_delta_mb": delta,
|
||
"burst_peak_mb": burst_peak,
|
||
"session_peak_mb": sess_peak,
|
||
"templates": {
|
||
"T4-ctrl": pack(t4, "T4-ctrl"),
|
||
"T4-medium": pack(t4, "T4-medium"),
|
||
"T4-shop": pack(t4, "T4-shop"),
|
||
"T4-amz": pack(t4, "T4-amz"),
|
||
"T4-t3x": pack(t4, "T4-t3x"),
|
||
},
|
||
"t5": {
|
||
"n": t5_n,
|
||
"parallelism": meta.get("t5_parallelism") or 4,
|
||
"queue_impl": meta.get("queue_impl") or "client_fifo",
|
||
"success": len(t5_ok),
|
||
"success_rate": t5_rate,
|
||
"p50_s": percentile(t5_times, 50),
|
||
"p95_s": t5_p95,
|
||
"oom": oom,
|
||
"http_429": 0,
|
||
"http_503": 0,
|
||
"first_complete_s": queue.get("first_complete_s"),
|
||
"last_complete_s": queue.get("last_complete_s"),
|
||
"queue_wait_p50_s": queue.get("queue_wait_p50_s"),
|
||
"queue_wait_p95_s": queue.get("queue_wait_p95_s"),
|
||
"rejected": queue.get("rejected") or 0,
|
||
"wall_s": meta.get("t5_wall_s"),
|
||
"note": "生产应是 SQLite WAL + CONCURRENT;本轮用脚本侧 worker 池代替",
|
||
},
|
||
"t4_waf": t4_waf,
|
||
"scorecard": {
|
||
"mem": mem,
|
||
"quality": q,
|
||
"stability": stab,
|
||
"latency": lat,
|
||
"ops": ops,
|
||
"total": total,
|
||
},
|
||
"notes": meta.get("notes") or [],
|
||
}
|
||
(ROOT / "results.json").write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
print(json.dumps({"wrote": str(ROOT / "results.json"), "scorecard": out["scorecard"]}, ensure_ascii=False))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|