Files
daily-robots/bot/browser_parser.py
2026-07-02 11:31:16 +08:00

308 lines
10 KiB
Python
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.

"""解析自然语言 / YAML / 场景名 → 浏览器步骤。"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
import yaml
import env_config
from browser_env import default_base_url, interpolate
from browser_models import BrowserScenario
SCENARIO_DIRS = [
Path(__file__).resolve().parent / "scenarios",
Path(__file__).resolve().parent.parent / "scenarios",
]
def _project_cwd() -> Path:
raw = env_config.env("CURSOR_CWD", r"d:\LY\test\tech") or r"d:\LY\test\tech"
return Path(raw).resolve()
def _strip_mention(text: str) -> str:
return re.sub(r"@\S+\s*", "", text).strip()
def _scenario_search_dirs() -> list[Path]:
dirs = list(SCENARIO_DIRS)
dirs.append(_project_cwd() / ".browser-scenarios")
custom = (env_config.env("BROWSER_SCENARIOS_DIR") or "").strip()
if custom:
dirs.append(Path(custom).resolve())
return dirs
def is_browser_intent(text: str) -> bool:
raw = _strip_mention(text)
if not raw:
return False
if re.match(r"^(browser|网页|网页操作|操作)\b", raw, re.IGNORECASE):
return True
if re.search(r"```(?:yaml|yml)", raw, re.IGNORECASE):
return True
if re.search(r"(?m)^browser\s*:", raw, re.IGNORECASE):
return True
if re.match(r"^(preview|截图|预览|截屏)\s", raw, re.IGNORECASE):
if not re.search(r"[,。;;]|然后|输入|点击|填写|访问|打开|登录", raw):
return False
if len(_split_segments(text)) >= 2:
return True
verbs = 0
for pattern in (r"访问", r"打开", r"输入", r"填写", r"点击", r"点选", r"选择", r"登录"):
if re.search(pattern, raw):
verbs += 1
return verbs >= 2
def _load_yaml_scenario(path: Path) -> BrowserScenario:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise RuntimeError(f"场景文件格式错误:{path}")
base_url = interpolate(str(data.get("base_url") or default_base_url()))
steps = data.get("steps")
if not isinstance(steps, list) or not steps:
raise RuntimeError(f"场景缺少 steps{path}")
return BrowserScenario(
name=data.get("name") or path.stem,
base_url=base_url,
steps=_normalize_steps(steps),
source=f"file:{path.name}",
)
def _find_scenario_file(name: str) -> Path | None:
slug = name.strip().replace(" ", "-")
for directory in _scenario_search_dirs():
for candidate in (directory / f"{slug}.yaml", directory / f"{slug}.yml"):
if candidate.exists():
return candidate
return None
def _normalize_steps(raw_steps: list[Any]) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
for item in raw_steps:
if isinstance(item, str):
normalized.append({"action": item})
continue
if not isinstance(item, dict) or not item:
raise RuntimeError(f"无效步骤:{item!r}")
if "action" in item:
normalized.append(dict(item))
continue
if len(item) == 1:
action, payload = next(iter(item.items()))
step = {"action": action}
if payload is not None:
if isinstance(payload, dict):
step.update(payload)
elif action == "wait" and isinstance(payload, int):
step["ms"] = payload
elif action == "wait" and isinstance(payload, str) and payload.isdigit():
step["ms"] = int(payload)
else:
step["target"] = payload
normalized.append(step)
continue
raise RuntimeError(f"无效步骤:{item!r}")
return normalized
def _parse_inline_dsl(text: str) -> BrowserScenario | None:
raw = _strip_mention(text)
match = re.search(r"(?ms)^browser\s*:\s*\n(.+)$", raw, re.IGNORECASE)
if not match:
return None
steps: list[dict[str, Any]] = []
for line in match.group(1).splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
line = re.sub(r"^[-*]\s*", "", line)
if not line:
continue
steps.append(_parse_dsl_line(line))
if not steps:
return None
return BrowserScenario(
name="inline",
base_url=default_base_url(),
steps=steps,
source="inline-dsl",
)
def _parse_dsl_line(line: str) -> dict[str, Any]:
parts = line.split(None, 2)
action = parts[0].lower()
if action == "goto":
return {"action": "goto", "target": parts[1] if len(parts) > 1 else "/"}
if action == "click":
return {"action": "click", "target": " ".join(parts[1:])}
if action == "fill":
if len(parts) < 3:
raise RuntimeError(f"fill 语法fill 字段 值({line}")
return {"action": "fill", "field": parts[1], "value": parts[2]}
if action == "wait":
payload = parts[1] if len(parts) > 1 else "1500"
if payload.isdigit():
return {"action": "wait", "ms": int(payload)}
return {"action": "wait", "url": payload}
if action in {"screenshot", "shot"}:
return {"action": "screenshot"}
raise RuntimeError(f"未知 DSL 步骤:{line}")
def _split_segments(text: str) -> list[str]:
raw = _strip_mention(text)
raw = re.sub(r"^(browser|网页|网页操作|操作)\s*[:]?\s*", "", raw, flags=re.IGNORECASE)
raw = re.sub(r"然后截图|再截图|最后截图", "截图", raw)
chunks = re.split(r"[,。;;]\s*|\s+然后\s+|\s+接着\s+|\s+并\s*", raw)
expanded: list[str] = []
for chunk in chunks:
chunk = chunk.strip()
if not chunk:
continue
subchunks = re.split(r"\s+然后\s+", chunk)
if "" in chunk and len(subchunks) == 1:
subchunks = re.split(r"(?<=[登录页表单])后(?=[进入打开等待点击访问])", chunk)
for part in subchunks:
part = part.strip()
if part:
expanded.append(part)
return expanded
def _parse_segment(segment: str) -> list[dict[str, Any]]:
seg = segment.strip()
if not seg or seg.lower() in {"browser", "网页操作"}:
return []
if re.fullmatch(r"截图|截屏", seg, re.IGNORECASE):
return [{"action": "screenshot"}]
match = re.search(r"访问登录页|打开登录页|进入登录页|运行登录页", seg, re.IGNORECASE)
if match:
return [{"action": "goto", "target": "/login"}]
match = re.search(r"输入账号密码|填写账号密码|输入账号和密码", seg, re.IGNORECASE)
if match:
return [
{"action": "fill", "field": "账号", "value": "{{PREVIEW_LOGIN_USER}}"},
{"action": "fill", "field": "密码", "value": "{{PREVIEW_LOGIN_PASSWORD}}"},
]
match = re.search(r"输入账号|填写账号|输入用户名|填写用户名", seg, re.IGNORECASE)
if match:
return [{"action": "fill", "field": "账号", "value": "{{PREVIEW_LOGIN_USER}}"}]
match = re.search(r"输入密码|填写密码", seg, re.IGNORECASE)
if match:
return [{"action": "fill", "field": "密码", "value": "{{PREVIEW_LOGIN_PASSWORD}}"}]
match = re.search(r"进入主页|进入首页|打开主页|打开首页|等待主页", seg, re.IGNORECASE)
if match:
return [{"action": "wait", "url": "**/app/**"}]
match = re.search(r"等待\s*(\d+)\s*秒", seg, re.IGNORECASE)
if match:
return [{"action": "wait", "ms": int(match.group(1)) * 1000}]
match = re.search(
r"(?:点击|点选|选择)\s*(.+?)(?:菜单|按钮|链接)?$",
seg,
re.IGNORECASE,
)
if match:
target = match.group(1).strip()
target = re.sub(r"(然后|再|并)?\s*(截图|截屏).*$", "", target, flags=re.IGNORECASE).strip()
target = re.sub(r"(然后|再|之后)$", "", target).strip()
target = re.sub(r"(菜单|按钮|链接)$", "", target).strip()
if target:
return [{"action": "click", "target": target}]
match = re.search(
r"(?:访问|打开|进入)\s*(https?://\S+|/\S+|登录页|主页|首页)",
seg,
re.IGNORECASE,
)
if match:
target = match.group(1)
mapping = {"登录页": "/login", "主页": "/app/dashboard", "首页": "/app/dashboard"}
return [{"action": "goto", "target": mapping.get(target, target)}]
return []
def parse_natural_language(text: str) -> BrowserScenario | None:
segments = _split_segments(text)
steps: list[dict[str, Any]] = []
for segment in segments:
steps.extend(_parse_segment(segment))
if not steps:
return None
if not any(step.get("action") == "screenshot" for step in steps):
if re.search(r"截图|截屏", text, re.IGNORECASE):
steps.append({"action": "screenshot"})
if not steps:
return None
return BrowserScenario(
name="natural",
base_url=default_base_url(),
steps=steps,
source="natural-language",
)
def parse_browser_request(text: str) -> BrowserScenario | None:
if not is_browser_intent(text):
return None
raw = _strip_mention(text)
yaml_block = re.search(r"```(?:yaml|yml)\s*\n(.+?)```", raw, re.IGNORECASE | re.DOTALL)
if yaml_block:
data = yaml.safe_load(yaml_block.group(1))
if isinstance(data, dict):
base_url = interpolate(str(data.get("base_url") or default_base_url()))
steps = data.get("steps") or []
return BrowserScenario(
name=data.get("name") or "yaml-inline",
base_url=base_url,
steps=_normalize_steps(steps),
source="yaml-inline",
)
inline = _parse_inline_dsl(text)
if inline:
return inline
match = re.match(r"^(browser|网页|网页操作|操作)\s+([\w\-./]+)\s*$", raw, re.IGNORECASE)
if match:
path = _find_scenario_file(match.group(2))
if not path:
raise RuntimeError(f"未找到场景文件:{match.group(2)}.yaml")
return _load_yaml_scenario(path)
scenario = parse_natural_language(text)
if scenario:
return scenario
default_name = (env_config.env("BROWSER_DEFAULT_SCENARIO") or "").strip()
if default_name:
path = _find_scenario_file(default_name)
if path:
return _load_yaml_scenario(path)
return None