onesvm-browser-server/bench/site-matrix/summarize.py
chii 983259836d chore: init workspace with onesvm-dev-md + casa-commander
docs: 联网搜索服务架构方案全套(plan-final/design-arch/选型决策/整合导览/MCP文档/部署预设/联调手册)
bench: 5 方案 + 代理 + 站点矩阵本机实测工程(无密钥)
部署目标:primary mgr1 先行测试(待批准后执行)
2026-09-01 15:19:52 +08:00

338 lines
16 KiB
Python
Executable file
Raw 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
"""Build results.json + site-matrix.md from raw/*.jsonl. No secrets."""
from __future__ import annotations
import datetime as dt
import json
import sys
from pathlib import Path
ROOT = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
RAW = ROOT / "raw"
ART = Path("/Users/chii/code/project/onesvm-browser-server/.dsh/artifacts/run-20260901-browser-arch")
TARGETS = json.loads((ROOT / "targets.json").read_text(encoding="utf-8"))
SITES = {s["id"]: s for s in TARGETS["sites"]}
def load_jsonl(name: str) -> list[dict]:
p = RAW / name
if not p.exists():
return []
rows = []
for line in p.read_text(encoding="utf-8").splitlines():
if line.strip():
rows.append(json.loads(line))
return rows
def load_side(prefix: str) -> dict[int, dict]:
out: dict[int, dict] = {}
for name in (f"{prefix}-foreign.jsonl", f"{prefix}-domestic.jsonl"):
for row in load_jsonl(name):
out[int(row["id"])] = row
# fallback: individual json files
if len(out) < 20:
for p in sorted(RAW.glob(f"{prefix}-*.json")):
if p.name.endswith(".excerpt.json") or ".listing." in p.name:
continue
try:
row = json.loads(p.read_text(encoding="utf-8"))
except Exception:
continue
if "id" in row:
out.setdefault(int(row["id"]), row)
return out
def yn(v: bool) -> str:
return "true" if v else "false"
def cell(row: dict | None) -> dict:
if not row:
return {
"http_status": None,
"final_url": "",
"title": "",
"title_ok": False,
"text_len": 0,
"extractable": False,
"intercept": "empty",
"elapsed_s": None,
"error": "missing_record",
}
return {
"http_status": row.get("http_status"),
"final_url": row.get("final_url") or "",
"title": row.get("title") or "",
"title_ok": bool(row.get("title_ok")),
"text_len": int(row.get("text_len") or 0),
"extractable": bool(row.get("extractable")),
"intercept": row.get("intercept") or "empty",
"elapsed_s": row.get("elapsed_s"),
"used_url": row.get("used_url") or row.get("start_url"),
"error": row.get("error"),
}
def bucket(http: dict, lp: dict) -> str:
if http.get("extractable"):
return "http_enough"
if lp.get("extractable"):
return "need_js"
return "egress_fail"
def scenario_verdict(rows: list[tuple[dict, dict, dict]]) -> dict[str, dict]:
# scenario -> list of (site, http, lp)
groups: dict[str, list] = {}
for site, h, l in rows:
key = site.get("scenario") or "其它"
groups.setdefault(key, []).append((site, h, l))
out = {}
for k, items in groups.items():
n = len(items)
http_ok = sum(1 for _, h, _ in items if h.get("extractable"))
js_ok = sum(1 for _, _, l in items if l.get("extractable"))
either = sum(1 for _, h, l in items if h.get("extractable") or l.get("extractable"))
if either == 0:
feas = "不可行(当前出口打不过)"
elif http_ok == n:
feas = "可行(纯 HTTP 足够)"
elif either == n and http_ok < n:
feas = "部分需 JS 渲染"
else:
feas = "部分可行"
out[k] = {
"n": n,
"http_ok": http_ok,
"js_ok": js_ok,
"either": either,
"feasibility": feas,
}
return out
def md_escape(s: str) -> str:
return (s or "").replace("|", "\\|").replace("\n", " ").strip()
def main() -> None:
http = load_side("http")
lp = load_side("lp")
now = dt.datetime.now().astimezone().isoformat(timespec="seconds")
matrix = []
pairs = []
for sid in range(1, 21):
site = SITES[sid]
h = cell(http.get(sid))
l = cell(lp.get(sid))
b = bucket(h, l)
row = {
"id": sid,
"slug": site["slug"],
"group": site["group"],
"scenario": site.get("scenario"),
"url": site["url"],
"used_url": h.get("used_url") or l.get("used_url") or site["url"],
"known_risk": site.get("known_risk"),
"http": h,
"lightpanda": l,
"class": b,
}
matrix.append(row)
pairs.append((site, h, l))
counts = {
"http_enough": sum(1 for r in matrix if r["class"] == "http_enough"),
"need_js": sum(1 for r in matrix if r["class"] == "need_js"),
"egress_fail": sum(1 for r in matrix if r["class"] == "egress_fail"),
"http_extractable": sum(1 for r in matrix if r["http"]["extractable"]),
"lp_extractable": sum(1 for r in matrix if r["lightpanda"]["extractable"]),
"recorded": sum(1 for r in matrix if r["http"]["http_status"] is not None or r["lightpanda"]["http_status"] is not None),
}
scen = scenario_verdict(pairs)
results = {
"probe": "PROBE-1",
"run": "run-20260901-browser-arch",
"probed_at": now,
"channels": ["trafilatura-http", "lightpanda"],
"timeout_s": 20,
"proxy": {
"mixed": "http://127.0.0.1:17890",
"container_via": "http://host.docker.internal:17890",
"foreign": "via_proxy",
"domestic": "direct",
},
"images": {
"trafilatura": "bench-s3b-trafilatura:local",
"lightpanda": "docker.io/lightpanda/browser:0.3.7",
},
"counts": counts,
"scenarios": scen,
"sites": matrix,
"notes": [
"http_status/final_url/title/text_len/elapsed 均为本机 Docker 实测,未编造状态码",
"intercept/title_ok/extractable 按 raw 标题与正文头重算(避免正文页误标 captcha/CF)",
"订阅明文未写入本文件",
],
}
(ROOT / "results.json").write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
ART.mkdir(parents=True, exist_ok=True)
lines: list[str] = []
lines.append("# 站点 × 通道能力矩阵(PROBE-1)")
lines.append("")
lines.append(f"> 实测时间:{now}(Asia/Shanghai)。通道:纯 HTTP = `trafilatura==2.2.0`(`bench-s3b-trafilatura:local`);轻量 JS = Lightpanda `0.3.7` CDP。国外 14 站经 mihomo mixed `127.0.0.1:17890`(容器侧 `host.docker.internal:17890`);国内 6 站直连。每站超时 20s。原始响应:`bench/site-matrix/raw/`。")
lines.append("")
lines.append("服务对象:Vlepontas(跨境电商/亚马逊卖家智能体)与内部 EAI。本探针回答「智能体直接给 URL 时,本服务两条通道各自能取到什么」。")
lines.append("")
lines.append("## 1. 矩阵表(20 站 × 2 通道)")
lines.append("")
lines.append("| # | 站 / 分组 | 通道 | HTTP | final_url | title | title正确 | text_len | 正文可提取 | 拦截 | 耗时s |")
lines.append("|---:|---|---|---:|---|---|---|---:|---|---|---:|")
for r in matrix:
label = f"{r['id']}. `{r['slug']}` / {r['group']}"
for ch, key in (("trafilatura", "http"), ("lightpanda", "lightpanda")):
c = r[key]
fu = md_escape(c.get("final_url") or "")
if len(fu) > 72:
fu = fu[:69] + "..."
title = md_escape(c.get("title") or "")
if len(title) > 40:
title = title[:37] + "..."
elapsed = c.get("elapsed_s")
elapsed_s = "" if elapsed is None else f"{elapsed:.2f}"
lines.append(
f"| {r['id'] if ch == 'trafilatura' else ''} | {label if ch == 'trafilatura' else ''} | {ch} | "
f"{c.get('http_status') if c.get('http_status') is not None else '—'} | `{fu}` | {title} | "
f"{yn(c.get('title_ok'))} | {c.get('text_len')} | {yn(c.get('extractable'))} | "
f"{c.get('intercept')} | {elapsed_s} |"
)
lines.append("")
lines.append("字段说明:`title正确` = 标题(或正文头)命中预定关键词且非拦截页;`正文可提取` = 拦截类型为 `none` 且文本长度达标;拦截枚举 `none/cloudflare/waf/captcha/redirect/empty/paywall`。")
lines.append("")
lines.append("## 2. 分类结论")
lines.append("")
lines.append(f"- 纯 HTTP 够用:{counts['http_enough']} 站")
lines.append(f"- 必须 JS 渲染(HTTP 失败、Lightpanda 成功):{counts['need_js']} 站")
lines.append(f"- 当前出口打不过(两条通道都未提取到可用正文):{counts['egress_fail']} 站")
lines.append(f"- 通道成功率:trafilatura {counts['http_extractable']}/20,lightpanda {counts['lp_extractable']}/20")
lines.append("")
http_ok = [r for r in matrix if r["class"] == "http_enough"]
need_js = [r for r in matrix if r["class"] == "need_js"]
fail = [r for r in matrix if r["class"] == "egress_fail"]
lines.append("### 2.1 纯 HTTP 够(trafilatura 可提取)")
lines.append("")
if http_ok:
for r in http_ok:
h = r["http"]
lines.append(
f"- **{r['id']}. {r['slug']}**({r['group']} / {r['scenario']}):HTTP {h['http_status']},"
f"`{h['intercept']}`,text_len={h['text_len']},{h['elapsed_s']}s。"
f"Lightpanda:HTTP {r['lightpanda']['http_status']} / {r['lightpanda']['intercept']} / text_len={r['lightpanda']['text_len']}。"
)
else:
lines.append("- (本轮无)")
lines.append("")
lines.append("这类站是百科、文档、部分新闻与政策静态页:智能体给 URL 后走 A 档(无浏览器 + trafilatura)即可,不必占渲染槽。")
lines.append("")
lines.append("### 2.2 必须 JS 渲染(仅 Lightpanda 可提取)")
lines.append("")
if need_js:
for r in need_js:
lines.append(
f"- **{r['id']}. {r['slug']}**:HTTP 通道 `{r['http']['intercept']}` status={r['http']['http_status']};"
f"Lightpanda `{r['lightpanda']['intercept']}` text_len={r['lightpanda']['text_len']}。"
)
else:
lines.append("- (本轮无:没有「仅 JS 成功」的站点)")
lines.append("")
lines.append("若本类为空,说明 Lightpanda 在本出口下并未比纯 HTTP 多打开多少强 SPA/反爬站;独立站/重前端页若 HTTP 已能抽到列表价或正文,则不必默认升级到 B 档。")
lines.append("")
lines.append("### 2.3 当前出口打不过")
lines.append("")
if fail:
for r in fail:
lines.append(
f"- **{r['id']}. {r['slug']}**(已知风险:{r.get('known_risk') or '—'}):"
f"HTTP {r['http']['http_status']}/{r['http']['intercept']};"
f"LP {r['lightpanda']['http_status']}/{r['lightpanda']['intercept']}。"
f" title_http=`{md_escape(r['http']['title'])[:60]}` title_lp=`{md_escape(r['lightpanda']['title'])[:60]}`。"
)
else:
lines.append("- (本轮无)")
lines.append("")
lines.append("典型失败面:Cloudflare 质询(Medium)、匿名 Reddit 403、Amazon WAF、X/LinkedIn 登录墙、微博/知乎反爬。数据中心 vless 出口 + 轻量无头内核打不过这些面;要打需住宅代理 + 保真/伪装核(Camoufox 预留档),不在 1GB 预算内。")
lines.append("")
lines.append("## 3. 对 Vlepontas / EAI 场景的含义")
lines.append("")
lines.append("判定口径:该场景代表站「任一通道 extractable=true」则可行;仅部分代表站通过则部分可行;全灭则当前出口不可行。")
lines.append("")
# explicit Vlepontas scenes mapped to site ids
vlep = [
("竞品调研(Amazon 商品 / GitHub 仓库 / LinkedIn 公司页)", [10, 4, 14]),
("独立站商品页(Allbirds 类 Shopify/自建站)", [11]),
("行业资讯(Wiki / HN / BBC / TechCrunch / Medium / 36氪 / 新华)", [1, 2, 6, 7, 8, 19, 18]),
("舆情(Reddit / X / 知乎 / 微博)", [9, 13, 16, 20]),
("政策(gov.cn 现行有效政策页)", [17]),
("技术文档(Stack Overflow / MDN)", [3, 5]),
("百科知识(Wikipedia / 百度百科)", [1, 15]),
]
lines.append("| 场景 | 代表站 | HTTP 可提取 | JS 可提取 | 任一通道 | 判定 |")
lines.append("|---|---|---:|---:|---:|---|")
for name, ids in vlep:
hs = sum(1 for i in ids if matrix[i - 1]["http"]["extractable"])
ls = sum(1 for i in ids if matrix[i - 1]["lightpanda"]["extractable"])
either = sum(1 for i in ids if matrix[i - 1]["http"]["extractable"] or matrix[i - 1]["lightpanda"]["extractable"])
n = len(ids)
if either == 0:
judge = "当前出口不可行"
elif hs == n:
judge = "可行,默认走纯 HTTP"
elif either == n:
judge = "可行,需按站分流 JS"
else:
judge = "部分可行,失败站需降级提示"
slugs = ", ".join(SITES[i]["slug"] for i in ids)
lines.append(f"| {name} | {slugs} | {hs}/{n} | {ls}/{n} | {either}/{n} | {judge} |")
lines.append("")
lines.append("### 3.1 场景逐条")
lines.append("")
lines.append("- **竞品调研**:Amazon 商品页在本代理池下两条通道都预期撞 WAF(S3c 已复现 Lightpanda 标题停在 `Amazon.com` 拦截页)。GitHub 仓库首页通常静态可抓。LinkedIn 公开公司页常被登录墙/空壳。**对 Vlepontas:不能把「粘贴 Amazon URL 出详情」当成默认能力**;应走官方 PA-API/已有 Daas,或 Camoufox+住宅代理特权意图。独立站(Allbirds)若本轮 HTTP 或 JS 能抽到商品名/价,则可作为「卖家给竞品独立站链接」的主路径。")
lines.append("- **舆情**:Reddit 匿名 403、X 首页登录墙、微博/知乎反爬是同一类问题——**不能作为实时舆情源**。EAI 若只要「用户贴了帖子 URL 读正文」,对 Reddit/X 应直接返回拦截类型,不要假装抽到了。")
lines.append("- **行业资讯**:Wiki/HN/部分新闻站是纯 HTTP 甜点。Medium 已知 CF。BBC/TechCrunch/36氪/新华取决于本轮真实状态码:能抽则资讯简报可用,被 CF/空壳则降级到搜索摘要。")
lines.append("- **政策**:国内直连 + trafilatura 是既定 P0(S3b 政策页精读已通过)。本轮验证「现行有效政策页」是否仍可抽;gov.cn 壳页 favor_precision 弃取是已知边界,宁可不抽不给导航残渣。")
lines.append("- **独立站**:Allbirds 类页 S3c 显示 Lightpanda 能渲染但 HTTP 可能 302/空。矩阵以本轮为准:能抽则 Vlepontas「竞品独立站」可用 B 档;抽不到价则还要 C 档 chrome-headless-shell。")
lines.append("- **EAI 内部**:MDN/SO/Wiki/GitHub/政策页覆盖「贴文档 URL 精读」。失败面主要是登录墙与 WAF,应用拦截类型回传,而不是重试到死。")
lines.append("")
lines.append("## 4. 路由建议(给能力路由器)")
lines.append("")
lines.append("1. 默认:国内域直连 trafilatura;国外域经 ProxyManager + trafilatura。")
lines.append("2. HTTP 拦截 ∈ `{empty, redirect}` 且站点像 SPA/独立站 → 升级 Lightpanda。")
lines.append("3. 拦截 ∈ `{cloudflare, waf, captcha}` → **不要**用 Lightpanda 空转;回传拦截类型;特权 key 才进 chrome-headless-shell / 预留 Camoufox。")
lines.append("4. Amazon 商品 URL 默认拒绝浏览器通道,改走结构化商品 API。")
lines.append("")
lines.append("## 5. 记录完整性")
lines.append("")
missing = [r["id"] for r in matrix if r["http"].get("http_status") is None or r["lightpanda"].get("http_status") is None]
if missing:
lines.append(f"- 缺失记录的站点 id:{missing}")
else:
lines.append("- 20 站 × 2 通道均有实测记录(失败亦保留真实状态码与拦截特征,无编造)。")
lines.append("- 原始文件:`bench/site-matrix/raw/http-*.json`、`lp-*.json`、`*.html.head`、`*.excerpt.json`。")
lines.append("- 订阅链接仅经环境变量 `PROXY_SUB_URL` 注入,未写入本目录。")
lines.append("")
(ART / "site-matrix.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"wrote {ROOT / 'results.json'}")
print(f"wrote {ART / 'site-matrix.md'}")
print(json.dumps(counts, ensure_ascii=False))
if __name__ == "__main__":
main()