e135ba9a84
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
160 lines
5.9 KiB
Python
160 lines
5.9 KiB
Python
"""比价卡死定位:读 pricebot trace 末段,判某平台是否原地打转(卡死)。
|
|
|
|
同机直读 {WORK_LOG_DIR}/{dir_name}/{platform}/step_*.json,只取头部字段
|
|
(pipeline_step/detected_page),不解析后面的无障碍树(windows,占单帧 99% 体积)。
|
|
判据与降级见 docs/superpowers/specs/2026-08-05-compare-stuck-detection-design.md。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
# pipeline_step / 平台名 → 中文(映射不到原样显示英文,不阻断)。按 pricebot 实际枚举补全。
|
|
PIPELINE_STEP_LABELS: dict[str, str] = {
|
|
"set_address": "定位",
|
|
"enter_store": "进店",
|
|
"add_one_dish": "加菜",
|
|
"match_dish": "找菜",
|
|
"checkout": "结算",
|
|
}
|
|
PLATFORM_LABELS: dict[str, str] = {
|
|
"meituan": "美团",
|
|
"eleme": "饿了么",
|
|
"jd_waimai": "京东外卖",
|
|
}
|
|
|
|
_PIPE_RE = re.compile(r'"pipeline_step":\s*"([^"]*)"')
|
|
_PAGE_RE = re.compile(r'"detected_page":\s*"([^"]*)"')
|
|
_TS_RE = re.compile(r'"timestamp":\s*"([^"]*)"')
|
|
_STEP_NUM_RE = re.compile(r"step_(\d+)")
|
|
|
|
|
|
def _parse_ts(s: str | None) -> datetime | None:
|
|
if not s:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(s)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StuckPoint:
|
|
platform: str
|
|
pipeline_step: str
|
|
frames: int # 末段连续困住的帧数(上限 max_tail)
|
|
stuck_ms: int | None = None # 末段连续卡住的时长(ms);无 timestamp 时 None
|
|
|
|
def label(self) -> str:
|
|
p = PLATFORM_LABELS.get(self.platform, self.platform)
|
|
s = PIPELINE_STEP_LABELS.get(self.pipeline_step, self.pipeline_step)
|
|
return f"{p}·{s}"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StuckResult:
|
|
readable: bool # trace 是否读到(区分「读不到」与「读到但没卡」)
|
|
points: list[StuckPoint] # 卡死平台列表;readable=True 且空 = 确认没卡死
|
|
|
|
|
|
def dir_name_from_trace_url(trace_url: str | None) -> str | None:
|
|
""".../traces/{dir_name}/ → dir_name;空/异常 → None。"""
|
|
if not trace_url:
|
|
return None
|
|
name = trace_url.rstrip("/").rsplit("/", 1)[-1]
|
|
return name or None
|
|
|
|
|
|
def _step_num(path: Path) -> int:
|
|
m = _STEP_NUM_RE.search(path.name)
|
|
return int(m.group(1)) if m else -1
|
|
|
|
|
|
def _read_head(path: Path, nbytes: int = 4096) -> tuple[str | None, str | None, str | None]:
|
|
"""只读文件头部,抠 (pipeline_step, detected_page, timestamp)。它们在 json 最前面。"""
|
|
try:
|
|
with open(path, encoding="utf-8", errors="replace") as f:
|
|
head = f.read(nbytes)
|
|
except OSError:
|
|
return None, None, None
|
|
ps = _PIPE_RE.search(head)
|
|
pg = _PAGE_RE.search(head)
|
|
ts = _TS_RE.search(head)
|
|
return (ps.group(1) if ps else None, pg.group(1) if pg else None, ts.group(1) if ts else None)
|
|
|
|
|
|
def _platform_stuck(
|
|
platform: str, step_files: list[Path], threshold: int, max_tail: int
|
|
) -> StuckPoint | None:
|
|
"""末帧往前数连续同 (pipeline_step, detected_page) 的帧数 ≥threshold → 卡死。"""
|
|
tail = step_files[-max_tail:]
|
|
heads = [_read_head(p) for p in tail] # [(ps, pg, ts), ...]
|
|
last_ps, last_pg, _ = heads[-1]
|
|
if last_ps is None:
|
|
return None
|
|
seg_ts: list[str | None] = [] # 连续段的 timestamp(逆序:末帧在前)
|
|
for ps, pg, ts in reversed(heads):
|
|
if ps == last_ps and pg == last_pg:
|
|
seg_ts.append(ts)
|
|
else:
|
|
break
|
|
count = len(seg_ts)
|
|
if count < threshold:
|
|
return None
|
|
# seg_ts[0]=末帧, seg_ts[-1]=段首帧;两端都能解析才算时长
|
|
t_last, t_first = _parse_ts(seg_ts[0]), _parse_ts(seg_ts[-1])
|
|
stuck_ms = round((t_last - t_first).total_seconds() * 1000) if t_last and t_first else None
|
|
if stuck_ms is not None and stuck_ms < 0:
|
|
stuck_ms = None # 时钟不单调(帧 timestamp 回退)→ 降级为不显示时长
|
|
return StuckPoint(platform, last_ps, count, stuck_ms)
|
|
|
|
|
|
def read_stuck_points(trace_dir: Path, *, threshold: int, max_tail: int) -> StuckResult:
|
|
"""逐平台判卡死。目录不存在/异常/无平台帧 → StuckResult(readable=False, points=[])。"""
|
|
try:
|
|
if not trace_dir.is_dir():
|
|
return StuckResult(readable=False, points=[])
|
|
points: list[StuckPoint] = []
|
|
any_frames = False
|
|
for pdir in sorted(trace_dir.iterdir()):
|
|
if not pdir.is_dir():
|
|
continue
|
|
step_files = sorted(pdir.glob("step_*.json"), key=_step_num)
|
|
if not step_files:
|
|
continue
|
|
any_frames = True
|
|
sp = _platform_stuck(pdir.name, step_files, threshold, max_tail)
|
|
if sp is not None:
|
|
points.append(sp)
|
|
if not any_frames:
|
|
return StuckResult(readable=False, points=[])
|
|
return StuckResult(readable=True, points=points)
|
|
except OSError:
|
|
return StuckResult(readable=False, points=[])
|
|
|
|
|
|
def last_step(trace_dir: Path) -> StuckPoint | None:
|
|
"""failed 用:取帧数最多平台的末帧环节(不要求原地打转)。读不到 → None。"""
|
|
try:
|
|
if not trace_dir.is_dir():
|
|
return None
|
|
best: tuple[int, str, list[Path]] | None = None
|
|
for pdir in sorted(trace_dir.iterdir()):
|
|
if not pdir.is_dir():
|
|
continue
|
|
step_files = sorted(pdir.glob("step_*.json"), key=_step_num)
|
|
# 平局(同帧数)时取字典序第一个平台(sorted 保证稳定)
|
|
if step_files and (best is None or len(step_files) > best[0]):
|
|
best = (len(step_files), pdir.name, step_files)
|
|
if best is None:
|
|
return None
|
|
_, platform, step_files = best
|
|
ps, _pg, _ts = _read_head(step_files[-1])
|
|
if ps is None:
|
|
return None
|
|
return StuckPoint(platform, ps, len(step_files)) # stuck_ms=None(failed 不算时长)
|
|
except OSError:
|
|
return None
|