308 lines
10 KiB
Python
308 lines
10 KiB
Python
"""解析自然语言 / 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
|