feat: add three-surface RPA locator standards and examples
All checks were successful
技能自动化发布 / release (push) Successful in 5s
All checks were successful
技能自动化发布 / release (push) Successful in 5s
This commit is contained in:
111
examples/real_desktop_rpa/scripts/service/desktop_session.py
Normal file
111
examples/real_desktop_rpa/scripts/service/desktop_session.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Windows 顶层窗口作用域:枚举、唯一性校验和激活。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import ntpath
|
||||
from ctypes import wintypes
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
from util.selectors import WindowSpec
|
||||
|
||||
|
||||
class DesktopRpaError(RuntimeError):
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(f"ERROR:{code} {message}")
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindowCandidate:
|
||||
hwnd: int
|
||||
pid: int
|
||||
process_name: str
|
||||
class_name: str
|
||||
title: str
|
||||
visible: bool
|
||||
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
SW_RESTORE = 9
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
|
||||
|
||||
def _process_name(pid: int) -> str:
|
||||
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return ""
|
||||
try:
|
||||
size = wintypes.DWORD(32768)
|
||||
buffer = ctypes.create_unicode_buffer(size.value)
|
||||
if not kernel32.QueryFullProcessImageNameW(
|
||||
handle,
|
||||
0,
|
||||
buffer,
|
||||
ctypes.byref(size),
|
||||
):
|
||||
return ""
|
||||
return ntpath.splitext(ntpath.basename(buffer.value))[0]
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
def enumerate_top_windows() -> list[WindowCandidate]:
|
||||
found: list[WindowCandidate] = []
|
||||
|
||||
@ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
|
||||
def callback(hwnd, _lparam):
|
||||
pid = wintypes.DWORD()
|
||||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
cls = ctypes.create_unicode_buffer(256)
|
||||
user32.GetClassNameW(hwnd, cls, 256)
|
||||
length = user32.GetWindowTextLengthW(hwnd)
|
||||
title = ctypes.create_unicode_buffer(length + 1)
|
||||
user32.GetWindowTextW(hwnd, title, length + 1)
|
||||
found.append(
|
||||
WindowCandidate(
|
||||
hwnd=int(hwnd),
|
||||
pid=int(pid.value),
|
||||
process_name=_process_name(int(pid.value)),
|
||||
class_name=cls.value,
|
||||
title=title.value,
|
||||
visible=bool(user32.IsWindowVisible(hwnd)),
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
user32.EnumWindows(callback, 0)
|
||||
return found
|
||||
|
||||
|
||||
def resolve_unique_window(
|
||||
spec: WindowSpec,
|
||||
*,
|
||||
candidates: Iterable[WindowCandidate] | None = None,
|
||||
) -> WindowCandidate:
|
||||
pool = list(candidates) if candidates is not None else enumerate_top_windows()
|
||||
matched = [
|
||||
item
|
||||
for item in pool
|
||||
if item.visible
|
||||
and item.process_name.casefold() == spec.process_name.casefold()
|
||||
and item.class_name == spec.class_name
|
||||
and (spec.title is None or item.title == spec.title)
|
||||
]
|
||||
if not matched:
|
||||
raise DesktopRpaError("WINDOW_NOT_FOUND", f"{spec.logical_name} 未找到")
|
||||
if len(matched) != 1:
|
||||
raise DesktopRpaError(
|
||||
"ELEMENT_AMBIGUOUS",
|
||||
f"{spec.logical_name} 匹配 {len(matched)} 个窗口,请按 PID/账号补充作用域",
|
||||
)
|
||||
return matched[0]
|
||||
|
||||
|
||||
def activate_window(window: WindowCandidate) -> None:
|
||||
user32.ShowWindow(window.hwnd, SW_RESTORE)
|
||||
if not user32.SetForegroundWindow(window.hwnd):
|
||||
raise DesktopRpaError("WINDOW_ACTIVATE_FAILED", f"无法激活 hwnd={window.hwnd}")
|
||||
96
examples/real_desktop_rpa/scripts/service/uia_locator.py
Normal file
96
examples/real_desktop_rpa/scripts/service/uia_locator.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""作用域内 UIA 唯一定位、真实点击和逐字输入。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any, Iterator
|
||||
|
||||
from service.desktop_session import DesktopRpaError, WindowCandidate, activate_window
|
||||
from util.selectors import ElementSpec
|
||||
|
||||
|
||||
def require_uiautomation():
|
||||
try:
|
||||
import uiautomation as auto
|
||||
except ImportError as exc:
|
||||
raise DesktopRpaError("UIA_UNAVAILABLE", "缺少 uiautomation 依赖") from exc
|
||||
return auto
|
||||
|
||||
|
||||
def root_from_window(window: WindowCandidate):
|
||||
auto = require_uiautomation()
|
||||
return auto.ControlFromHandle(window.hwnd)
|
||||
|
||||
|
||||
def _walk(root: Any, *, depth: int = 0, max_depth: int = 25) -> Iterator[Any]:
|
||||
if depth > max_depth:
|
||||
return
|
||||
yield root
|
||||
if depth == max_depth:
|
||||
return
|
||||
try:
|
||||
children = root.GetChildren()
|
||||
except Exception:
|
||||
children = []
|
||||
for child in children:
|
||||
yield from _walk(child, depth=depth + 1, max_depth=max_depth)
|
||||
|
||||
|
||||
def _matches(control: Any, spec: ElementSpec) -> bool:
|
||||
if spec.name is not None and (getattr(control, "Name", None) or "") != spec.name:
|
||||
return False
|
||||
if spec.automation_id is not None and (
|
||||
getattr(control, "AutomationId", None) or ""
|
||||
) != spec.automation_id:
|
||||
return False
|
||||
if spec.control_type is not None and (
|
||||
(getattr(control, "ControlTypeName", None) or "").removesuffix("Control")
|
||||
!= spec.control_type.removesuffix("Control")
|
||||
):
|
||||
return False
|
||||
if spec.class_name is not None and (
|
||||
getattr(control, "ClassName", None) or ""
|
||||
) != spec.class_name:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def locate_unique(root: Any, spec: ElementSpec, *, max_depth: int = 25):
|
||||
search_root = root
|
||||
if spec.ancestor_name:
|
||||
ancestor_spec = ElementSpec(
|
||||
logical_name=f"{spec.logical_name}.ancestor",
|
||||
name=spec.ancestor_name,
|
||||
)
|
||||
search_root = locate_unique(root, ancestor_spec, max_depth=max_depth)
|
||||
|
||||
matched = [control for control in _walk(search_root, max_depth=max_depth) if _matches(control, spec)]
|
||||
if not matched:
|
||||
raise DesktopRpaError("ELEMENT_NOT_FOUND", f"{spec.logical_name} 未找到")
|
||||
if len(matched) != spec.expected_count:
|
||||
raise DesktopRpaError(
|
||||
"ELEMENT_AMBIGUOUS",
|
||||
f"{spec.logical_name} 期望 {spec.expected_count} 个,实际 {len(matched)} 个",
|
||||
)
|
||||
return matched[0]
|
||||
|
||||
|
||||
def click_control(control: Any, *, window: WindowCandidate) -> None:
|
||||
activate_window(window)
|
||||
rect = control.BoundingRectangle
|
||||
if rect.width() <= 0 or rect.height() <= 0 or rect.left < -10000:
|
||||
raise DesktopRpaError("ELEMENT_NOT_READY", "目标元素不可见或边界矩形无效")
|
||||
control.Click(
|
||||
x=random.randint(-2, 2),
|
||||
y=random.randint(-2, 2),
|
||||
simulateMove=True,
|
||||
)
|
||||
|
||||
|
||||
def type_text(control: Any, text: str, *, window: WindowCandidate) -> None:
|
||||
auto = require_uiautomation()
|
||||
click_control(control, window=window)
|
||||
auto.SendKeys("{Ctrl}a", waitTime=0.05)
|
||||
auto.SendKeys("{Delete}", waitTime=0.05)
|
||||
for char in text:
|
||||
auto.SendKeys(char, waitTime=random.uniform(0.04, 0.12))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""企业微信成功案例的安全提炼:probe 和 dry-run,不默认发送邀请。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from service.desktop_session import resolve_unique_window
|
||||
from service.uia_locator import locate_unique, root_from_window, type_text
|
||||
from util.selectors import ADD_CUSTOMER_WINDOW, CONTACTS_ENTRY, MAIN_WINDOW, SEARCH_INPUT
|
||||
|
||||
|
||||
def probe() -> dict[str, Any]:
|
||||
main = resolve_unique_window(MAIN_WINDOW)
|
||||
root = root_from_window(main)
|
||||
contacts = locate_unique(root, CONTACTS_ENTRY)
|
||||
return {
|
||||
"success": True,
|
||||
"main_window": asdict(main),
|
||||
"contacts_entry": {
|
||||
"name": getattr(contacts, "Name", ""),
|
||||
"control_type": getattr(contacts, "ControlTypeName", ""),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def fill_search_dry_run(target: str) -> dict[str, Any]:
|
||||
"""要求操作者已打开“添加客户”窗口;只填写搜索框,不提交。"""
|
||||
search_window = resolve_unique_window(ADD_CUSTOMER_WINDOW)
|
||||
root = root_from_window(search_window)
|
||||
search_input = locate_unique(root, SEARCH_INPUT)
|
||||
type_text(search_input, target, window=search_window)
|
||||
return {
|
||||
"success": True,
|
||||
"mode": "dry-run",
|
||||
"message": "已填写搜索条件,未发送邀请",
|
||||
}
|
||||
44
examples/real_desktop_rpa/scripts/util/selectors.py
Normal file
44
examples/real_desktop_rpa/scripts/util/selectors.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""脱敏 UIA element specs;复制到新技能后必须用真实拾取结果替换。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindowSpec:
|
||||
logical_name: str
|
||||
process_name: str
|
||||
class_name: str
|
||||
title: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ElementSpec:
|
||||
logical_name: str
|
||||
name: Optional[str] = None
|
||||
automation_id: Optional[str] = None
|
||||
control_type: Optional[str] = None
|
||||
class_name: Optional[str] = None
|
||||
ancestor_name: Optional[str] = None
|
||||
expected_count: int = 1
|
||||
|
||||
|
||||
MAIN_WINDOW = WindowSpec("main_window", "WXWork", "WeWorkWindow", "企业微信")
|
||||
ADD_CUSTOMER_WINDOW = WindowSpec(
|
||||
"add_customer_window",
|
||||
"WXWork",
|
||||
"SearchExternalsWnd",
|
||||
"添加客户",
|
||||
)
|
||||
CONTACTS_ENTRY = ElementSpec(
|
||||
"contacts_entry",
|
||||
name="org_op_new",
|
||||
control_type="TreeItem",
|
||||
)
|
||||
SEARCH_INPUT = ElementSpec(
|
||||
"search_input",
|
||||
name="searchedit",
|
||||
ancestor_name="search_layout",
|
||||
)
|
||||
Reference in New Issue
Block a user