778 lines
28 KiB
Python
778 lines
28 KiB
Python
"""
|
||
Agent端技能基类(含防封拟人化行为层 + 深层防护集成)
|
||
"""
|
||
|
||
import time
|
||
import random
|
||
import logging
|
||
import shlex
|
||
from typing import Dict, Any, List, Optional, Tuple
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class BaseSkill:
|
||
"""技能基类"""
|
||
|
||
PACKAGE: str = ""
|
||
NAME: str = ""
|
||
SPEED_MULTIPLIER: float = 0.3
|
||
|
||
def __init__(self, device, bus=None, anti_ban_ctx=None):
|
||
self.d = device
|
||
self.bus = bus
|
||
self._ab = anti_ban_ctx or {}
|
||
self._risk = self._ab.get("risk_sentinel")
|
||
self._touch = self._ab.get("touch_hardener")
|
||
self._sensor = self._ab.get("sensor_sim")
|
||
self._ui_cache = None
|
||
self._ui_cache_time = 0
|
||
|
||
# ==========================================================
|
||
# 防封拟人化行为层(所有 Skill 操作必须使用这些方法)
|
||
# ==========================================================
|
||
|
||
@staticmethod
|
||
def human_delay(min_sec: float = 0.5, max_sec: float = 3.0):
|
||
"""拟人延迟:高斯分布,均值在 min-max 中间,避免机械化固定间隔"""
|
||
mean = (min_sec + max_sec) / 2
|
||
std = (max_sec - min_sec) / 6
|
||
delay = max(min_sec, min(max_sec, random.gauss(mean, std)))
|
||
time.sleep(delay)
|
||
|
||
def human_type(self, text: str, clear: bool = True):
|
||
"""拟人输入:逐字输入 + 随机间隔 + 偶尔打错重输"""
|
||
if clear:
|
||
self.d.clear_text()
|
||
self.human_delay(0.2, 0.5)
|
||
for i, char in enumerate(text):
|
||
self.d.send_keys(char)
|
||
time.sleep(random.uniform(0.04, 0.18))
|
||
if random.random() < 0.03 and i < len(text) - 1:
|
||
wrong = random.choice("abcdefg1234")
|
||
self.d.send_keys(wrong)
|
||
time.sleep(random.uniform(0.1, 0.25))
|
||
self.d.press("del")
|
||
time.sleep(random.uniform(0.08, 0.15))
|
||
|
||
def human_click(self, x: int, y: int, offset: int = 8):
|
||
"""拟人点击:加微小随机偏移(root tap 优先绕 BUG-8)"""
|
||
dx = random.randint(-offset, offset)
|
||
dy = random.randint(-offset, offset)
|
||
tx, ty = x + dx, y + dy
|
||
if self._root_input_available() and self._root_tap(tx, ty):
|
||
self.human_delay(0.05, 0.2)
|
||
return
|
||
if self._touch:
|
||
try:
|
||
self._touch.tap(tx, ty, randomize=False)
|
||
except Exception:
|
||
self.d.click(tx, ty)
|
||
else:
|
||
self.d.click(tx, ty)
|
||
self.human_delay(0.05, 0.2)
|
||
|
||
def human_swipe(
|
||
self,
|
||
start: Tuple[int, int],
|
||
end: Tuple[int, int],
|
||
duration: Optional[float] = None,
|
||
):
|
||
"""拟人滑动:贝塞尔曲线轨迹 + 触摸加固通道 + 传感器模拟"""
|
||
if self._risk and not self._risk.can_operate():
|
||
return
|
||
if duration is None:
|
||
duration = random.uniform(0.3, 0.8)
|
||
# 滑动前注入手持抖动
|
||
if self._sensor:
|
||
self._sensor.simulate_hand_shake(duration=random.uniform(0.2, 0.5))
|
||
ctrl_x = (start[0] + end[0]) // 2 + random.randint(-30, 30)
|
||
ctrl_y = (start[1] + end[1]) // 2 + random.randint(-30, 30)
|
||
steps = max(8, int(duration * 40))
|
||
points = []
|
||
for i in range(steps + 1):
|
||
t = i / steps
|
||
bx = (1 - t) ** 2 * start[0] + 2 * (1 - t) * t * ctrl_x + t ** 2 * end[0]
|
||
by = (1 - t) ** 2 * start[1] + 2 * (1 - t) * t * ctrl_y + t ** 2 * end[1]
|
||
points.append((int(bx), int(by)))
|
||
# root swipe 优先绕 BUG-8;否则 shell 加固通道 / u2
|
||
if self._root_input_available() and self._root_swipe(start[0], start[1], end[0], end[1], int(duration * 1000)):
|
||
return
|
||
if self._touch:
|
||
self._touch.swipe(start, end, int(duration * 1000))
|
||
else:
|
||
try:
|
||
self.d.swipe_points(points, duration)
|
||
except (AttributeError, TypeError):
|
||
self.d.swipe(start[0], start[1], end[0], end[1], duration=duration)
|
||
|
||
def random_browse(self, duration_sec: float = 30):
|
||
"""随机浏览:模拟真人无目的翻看,用于养号/填充自然行为"""
|
||
try:
|
||
info = self.d.info
|
||
w = info.get("displayWidth", 1080)
|
||
h = info.get("displayHeight", 2400)
|
||
except Exception:
|
||
w, h = 1080, 2400
|
||
end_time = time.time() + duration_sec
|
||
while time.time() < end_time:
|
||
action = random.choice(["scroll", "pause", "scroll", "pause", "tap_safe"])
|
||
if action == "scroll":
|
||
self.human_swipe((w // 2, int(h * 0.75)), (w // 2, int(h * 0.25)))
|
||
self.human_delay(1.5, 4.0)
|
||
elif action == "pause":
|
||
self.human_delay(2.0, 6.0)
|
||
elif action == "tap_safe":
|
||
safe_x = random.randint(int(w * 0.1), int(w * 0.9))
|
||
safe_y = random.randint(int(h * 0.2), int(h * 0.7))
|
||
self.human_click(safe_x, safe_y, offset=5)
|
||
self.human_delay(1.0, 3.0)
|
||
self.d.press("back")
|
||
self.human_delay(0.5, 1.5)
|
||
|
||
def natural_behavior_before(self, action: str = "send_message"):
|
||
"""自然行为链前置:快速模式下仅做最小延迟"""
|
||
multiplier = self.SPEED_MULTIPLIER
|
||
high_risk = {"send_message", "add_friend", "post_moments", "batch_send", "mass_send"}
|
||
trigger_rate = min(0.9, (0.5 if action in high_risk else 0.3) * multiplier)
|
||
if random.random() > trigger_rate:
|
||
return
|
||
try:
|
||
info = self.d.info
|
||
w = info.get("displayWidth", 1080)
|
||
h = info.get("displayHeight", 2400)
|
||
except Exception:
|
||
w, h = 1080, 2400
|
||
self.human_delay(0.8 * multiplier, 2.0 * multiplier)
|
||
if random.random() < 0.6:
|
||
self.human_swipe((w // 2, int(h * 0.3)), (w // 2, int(h * 0.7)))
|
||
self.human_delay(1.0 * multiplier, 3.0 * multiplier)
|
||
self.human_delay(0.5 * multiplier, 1.5 * multiplier)
|
||
|
||
def natural_behavior_after(self, action: str = "send_message"):
|
||
"""自然行为链后置:快速模式下跳过"""
|
||
multiplier = self.SPEED_MULTIPLIER
|
||
trigger = min(0.8, 0.3 * multiplier)
|
||
if random.random() > trigger:
|
||
return
|
||
self.human_delay(1.0 * multiplier, 3.0 * multiplier)
|
||
self.random_browse(duration_sec=random.uniform(5, 15) * multiplier)
|
||
|
||
def say(self, message: str, to_skill: Optional[str] = None, data: Optional[Dict[str, Any]] = None):
|
||
"""向总线发一条消息,其它 Skill 可通过 read_chat 看到"""
|
||
if self.bus:
|
||
self.bus.append(self.NAME, message, to_skill=to_skill, data=data or {})
|
||
|
||
def read_chat(self, since_index: int = 0, from_skill: Optional[str] = None) -> List[Dict[str, Any]]:
|
||
"""读取总线上其它 Skill 的消息"""
|
||
if not self.bus:
|
||
return []
|
||
return self.bus.get_messages(since_index=since_index, from_skill=from_skill)
|
||
|
||
def last_from(self, skill_name: str) -> Optional[Dict[str, Any]]:
|
||
"""取指定 Skill 最后一条消息"""
|
||
if not self.bus:
|
||
return None
|
||
return self.bus.get_last(from_skill=skill_name)
|
||
|
||
# ========== APP控制 ==========
|
||
|
||
def launch(self) -> bool:
|
||
"""启动APP(短间隔,界面确认由 wait_for_app_ready 负责)"""
|
||
try:
|
||
self.d.app_start(self.PACKAGE)
|
||
time.sleep(0.5)
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"启动{self.NAME}失败: {e}")
|
||
return False
|
||
|
||
def close(self):
|
||
"""关闭APP"""
|
||
self.d.app_stop(self.PACKAGE)
|
||
|
||
def is_running(self) -> bool:
|
||
"""检查是否运行"""
|
||
try:
|
||
return self.d.app_current()['package'] == self.PACKAGE
|
||
except:
|
||
return False
|
||
|
||
# ========== UI操作 ==========
|
||
# BUG-8 修复:MIUI/红米下「USB调试(安全设置)」未开时,u2 的 injectInputEvent 被
|
||
# INJECT_EVENTS 权限拦截(连 adb shell input 也报 SecurityException)。设备已 Root,
|
||
# 故经 `su -c 'input ...'`(root 上下文)绕过该限制(真机验证 exit=0 无异常)。
|
||
# 元素点击:先用 u2 dump 取 bounds(只读,不需注入),再 root tap 中心。
|
||
|
||
def _root_input_available(self) -> bool:
|
||
"""检测 root input 是否可用(缓存)。"""
|
||
cached = getattr(self, "_root_input_ok", None)
|
||
if cached is not None:
|
||
return cached
|
||
ok = False
|
||
try:
|
||
out = (self.d.shell("su -c id").output or "")
|
||
ok = "uid=0" in out
|
||
except Exception:
|
||
ok = False
|
||
self._root_input_ok = ok
|
||
return ok
|
||
|
||
def _su_input(self, args: str) -> bool:
|
||
try:
|
||
self.d.shell("su -c 'input %s'" % args)
|
||
return True
|
||
except Exception as e:
|
||
logger.debug(f"root input 失败: {e}")
|
||
return False
|
||
|
||
def _root_text(self, text: str) -> bool:
|
||
"""通过 root input text 输入 ASCII 文本,绕过 ADBKeyboard 安装/启用失败。"""
|
||
if not self._root_input_available():
|
||
return False
|
||
try:
|
||
# Android input text 对空格使用 %s;复杂 Unicode 仍优先走系统/u2 输入法。
|
||
escaped = str(text).replace("%", "\\%").replace(" ", "%s")
|
||
cmd = "input text " + shlex.quote(escaped)
|
||
self.d.shell("su -c %s" % shlex.quote(cmd))
|
||
return True
|
||
except Exception as e:
|
||
logger.debug(f"root text 输入失败: {e}")
|
||
return False
|
||
|
||
def _root_tap(self, x: int, y: int) -> bool:
|
||
return self._su_input("tap %d %d" % (int(x), int(y)))
|
||
|
||
def _root_swipe(self, x1: int, y1: int, x2: int, y2: int, ms: int = 300) -> bool:
|
||
return self._su_input("swipe %d %d %d %d %d" % (int(x1), int(y1), int(x2), int(y2), int(ms)))
|
||
|
||
def _root_key(self, keycode: int) -> bool:
|
||
return self._su_input("keyevent %d" % int(keycode))
|
||
|
||
def _element_center(self, sel):
|
||
"""取 u2 元素中心坐标(bounds 只读,不需 INJECT_EVENTS)。"""
|
||
try:
|
||
if sel.exists:
|
||
b = sel.info.get("bounds") or {}
|
||
if b:
|
||
return (b["left"] + b["right"]) // 2, (b["top"] + b["bottom"]) // 2
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def _click_selector(self, sel) -> bool:
|
||
"""优先 root tap 元素中心(绕 BUG-8),失败回退 u2 click。"""
|
||
if self._root_input_available():
|
||
c = self._element_center(sel)
|
||
if c and self._root_tap(c[0], c[1]):
|
||
return True
|
||
try:
|
||
sel.click()
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def click(self, x: int, y: int):
|
||
"""点击坐标(root 优先绕 BUG-8)"""
|
||
if self._root_input_available() and self._root_tap(x, y):
|
||
return
|
||
self.d.click(x, y)
|
||
|
||
# 常用按键名 → keyevent 码(绕 BUG-8 的 root keyevent)
|
||
_KEYMAP = {"back": 4, "home": 3, "enter": 66, "menu": 82, "del": 67, "search": 84}
|
||
|
||
def press(self, key) -> None:
|
||
"""按键(root keyevent 优先绕 BUG-8),兼容 self.d.press 的键名/码。"""
|
||
code = self._KEYMAP.get(key, key) if isinstance(key, str) else key
|
||
if isinstance(code, int) and self._root_input_available() and self._root_key(code):
|
||
return
|
||
try:
|
||
self.d.press(key)
|
||
except Exception:
|
||
pass
|
||
|
||
def long_click(self, x: int, y: int, duration: float = 1.0) -> None:
|
||
"""长按(root:用 input swipe 原地停留 duration 模拟长按,绕 BUG-8)。"""
|
||
if self._root_input_available() and self._root_swipe(x, y, x, y, int(duration * 1000)):
|
||
return
|
||
try:
|
||
self.d.long_click(x, y, duration)
|
||
except Exception:
|
||
pass
|
||
|
||
def click_text(self, text: str, timeout: float = 10) -> bool:
|
||
"""点击文字"""
|
||
try:
|
||
sel = self.d(text=text)
|
||
if sel.wait(timeout=timeout):
|
||
return self._click_selector(sel)
|
||
return False
|
||
except:
|
||
return False
|
||
|
||
def click_contains(self, text: str, timeout: float = 10) -> bool:
|
||
"""点击包含文字的元素"""
|
||
try:
|
||
sel = self.d(textContains=text)
|
||
if sel.wait(timeout=timeout):
|
||
return self._click_selector(sel)
|
||
return False
|
||
except:
|
||
return False
|
||
|
||
def click_id(self, resource_id: str) -> bool:
|
||
"""点击资源ID"""
|
||
try:
|
||
return self._click_selector(self.d(resourceId=resource_id))
|
||
except:
|
||
return False
|
||
|
||
def click_desc(self, desc: str) -> bool:
|
||
"""点击content-desc"""
|
||
try:
|
||
return self._click_selector(self.d(description=desc))
|
||
except:
|
||
return False
|
||
|
||
def input_text(self, text: str, clear: bool = True):
|
||
"""输入文字"""
|
||
if clear:
|
||
try:
|
||
self.d.clear_text()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self.d.send_keys(text)
|
||
return
|
||
except Exception as e:
|
||
logger.warning(f"u2 输入失败,尝试 root input text: {e}")
|
||
if not self._root_text(text):
|
||
raise RuntimeError("文本输入失败:u2/ADBKeyboard 不可用,root input text 兜底失败")
|
||
|
||
def input_to(self, resource_id: str, text: str, clear: bool = True):
|
||
"""向指定元素输入"""
|
||
elem = self.d(resourceId=resource_id)
|
||
if clear:
|
||
elem.clear_text()
|
||
elem.send_keys(text)
|
||
|
||
def swipe(self, direction: str, scale: float = 0.8):
|
||
"""滑动"""
|
||
self.d.swipe_ext(direction, scale=scale)
|
||
|
||
def swipe_up(self, scale: float = 0.5):
|
||
"""向上滑动"""
|
||
self.d.swipe_ext("up", scale=scale)
|
||
|
||
def swipe_down(self, scale: float = 0.5):
|
||
"""向下滑动"""
|
||
self.d.swipe_ext("down", scale=scale)
|
||
|
||
# ========== 查找元素 ==========
|
||
|
||
def exists(self, text: str = None, resource_id: str = None, timeout: float = 3) -> bool:
|
||
"""检查元素是否存在"""
|
||
if text:
|
||
return self.d(text=text).exists(timeout=timeout)
|
||
if resource_id:
|
||
return self.d(resourceId=resource_id).exists(timeout=timeout)
|
||
return False
|
||
|
||
def wait_for(self, text: str = None, resource_id: str = None, timeout: float = 10) -> bool:
|
||
"""等待元素出现"""
|
||
if text:
|
||
return self.d(text=text).wait(timeout=timeout)
|
||
if resource_id:
|
||
return self.d(resourceId=resource_id).wait(timeout=timeout)
|
||
return False
|
||
|
||
def wait_gone(self, text: str = None, resource_id: str = None, timeout: float = 10) -> bool:
|
||
"""等待元素消失"""
|
||
if text:
|
||
return self.d(text=text).wait_gone(timeout=timeout)
|
||
if resource_id:
|
||
return self.d(resourceId=resource_id).wait_gone(timeout=timeout)
|
||
return False
|
||
|
||
# ========== 工具方法 ==========
|
||
|
||
def screenshot(self) -> bytes:
|
||
"""截图"""
|
||
return self.d.screenshot(format='raw')
|
||
|
||
def get_ui_tree(self, max_age: float = 2.0) -> str:
|
||
"""获取UI树(带缓存,max_age 秒内复用上次结果)"""
|
||
now = time.time()
|
||
if self._ui_cache and (now - self._ui_cache_time) < max_age:
|
||
return self._ui_cache
|
||
self._ui_cache = self.d.dump_hierarchy()
|
||
self._ui_cache_time = now
|
||
return self._ui_cache
|
||
|
||
def invalidate_ui_cache(self):
|
||
"""手动失效UI缓存(点击/输入后调用)"""
|
||
self._ui_cache = None
|
||
self._ui_cache_time = 0
|
||
|
||
def sleep(self, seconds: float):
|
||
"""等待(应用速度乘数)"""
|
||
time.sleep(seconds * self.SPEED_MULTIPLIER)
|
||
|
||
def back(self):
|
||
"""返回键"""
|
||
self.d.press("back")
|
||
|
||
def home(self):
|
||
"""Home键"""
|
||
self.d.press("home")
|
||
|
||
# ========== 新增功能:搜索 ==========
|
||
|
||
def search(self, keyword: str, wait_time: float = 2.0) -> Dict[str, Any]:
|
||
"""
|
||
在APP内搜索
|
||
|
||
Args:
|
||
keyword: 搜索关键词
|
||
wait_time: 等待应用加载时间
|
||
|
||
Returns:
|
||
搜索结果
|
||
"""
|
||
try:
|
||
self.sleep(wait_time)
|
||
|
||
# 方法1: 查找搜索框(常见位置)
|
||
search_found = False
|
||
|
||
# 尝试点击搜索图标/文字
|
||
search_selectors = [
|
||
("text", "搜索"),
|
||
("description", "搜索"),
|
||
("textContains", "搜索"),
|
||
("resourceId", "search"),
|
||
]
|
||
|
||
for selector_type, selector_value in search_selectors:
|
||
try:
|
||
if selector_type == "text":
|
||
if self.d(text=selector_value).exists(timeout=1):
|
||
self.d(text=selector_value).click()
|
||
search_found = True
|
||
break
|
||
elif selector_type == "description":
|
||
if self.d(description=selector_value).exists(timeout=1):
|
||
self.d(description=selector_value).click()
|
||
search_found = True
|
||
break
|
||
elif selector_type == "textContains":
|
||
if self.d(textContains=selector_value).exists(timeout=1):
|
||
self.d(textContains=selector_value).click()
|
||
search_found = True
|
||
break
|
||
elif selector_type == "resourceId":
|
||
if self.d(resourceId=selector_value).exists(timeout=1):
|
||
self.d(resourceId=selector_value).click()
|
||
search_found = True
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# 方法2: 如果没找到,尝试点击屏幕上方(常见搜索框位置)
|
||
if not search_found:
|
||
info = self.d.info
|
||
# 点击屏幕上方中间位置
|
||
self.d.click(info['displayWidth'] // 2, info['displayHeight'] // 8)
|
||
self.sleep(0.5)
|
||
|
||
# 输入搜索关键词
|
||
self.sleep(0.5)
|
||
self.input_text(keyword, clear=True)
|
||
self.sleep(1)
|
||
|
||
# 执行搜索(回车或点击搜索按钮)
|
||
try:
|
||
self.d.press("enter")
|
||
except:
|
||
# 尝试点击搜索按钮
|
||
if self.click_text("搜索") or self.click_text("确定"):
|
||
pass
|
||
|
||
self.sleep(1)
|
||
|
||
return {
|
||
"success": True,
|
||
"keyword": keyword,
|
||
"message": f"已搜索: {keyword}"
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"搜索失败: {e}")
|
||
return {"success": False, "error": str(e), "keyword": keyword}
|
||
|
||
# ========== 新增功能:复合命令 ==========
|
||
|
||
def execute_compound_command(self, commands: list) -> Dict[str, Any]:
|
||
"""
|
||
执行复合命令
|
||
|
||
Args:
|
||
commands: 命令列表,每个命令是 {"action": "...", "params": {...}}
|
||
|
||
Returns:
|
||
执行结果
|
||
"""
|
||
results = []
|
||
|
||
for i, cmd in enumerate(commands):
|
||
action = cmd.get("action", "")
|
||
params = cmd.get("params", {})
|
||
|
||
try:
|
||
if action == "open_app":
|
||
result = self.launch()
|
||
results.append({"action": action, "success": result})
|
||
self.sleep(2) # 等待应用启动
|
||
|
||
elif action == "search":
|
||
keyword = params.get("keyword", "")
|
||
result = self.search(keyword)
|
||
results.append(result)
|
||
|
||
elif action == "click":
|
||
x = params.get("x", 0)
|
||
y = params.get("y", 0)
|
||
self.click(x, y)
|
||
results.append({"action": action, "success": True})
|
||
self.sleep(0.5)
|
||
|
||
elif action == "swipe":
|
||
direction = params.get("direction", "up")
|
||
scale = params.get("scale", 0.5)
|
||
self.swipe(direction, scale)
|
||
results.append({"action": action, "success": True})
|
||
self.sleep(0.5)
|
||
|
||
elif action == "input_text":
|
||
text = params.get("text", "")
|
||
self.input_text(text)
|
||
results.append({"action": action, "success": True})
|
||
self.sleep(0.5)
|
||
|
||
elif action == "back":
|
||
self.back()
|
||
results.append({"action": action, "success": True})
|
||
self.sleep(0.5)
|
||
|
||
elif action == "home":
|
||
self.home()
|
||
results.append({"action": action, "success": True})
|
||
self.sleep(0.5)
|
||
|
||
elif action == "wait":
|
||
seconds = params.get("seconds", 1)
|
||
self.sleep(seconds)
|
||
results.append({"action": action, "success": True})
|
||
|
||
else:
|
||
results.append({
|
||
"action": action,
|
||
"success": False,
|
||
"error": f"未知操作: {action}"
|
||
})
|
||
|
||
except Exception as e:
|
||
results.append({
|
||
"action": action,
|
||
"success": False,
|
||
"error": str(e)
|
||
})
|
||
|
||
success_count = sum(1 for r in results if r.get("success", False))
|
||
|
||
return {
|
||
"success": success_count == len(commands),
|
||
"total": len(commands),
|
||
"success_count": success_count,
|
||
"results": results
|
||
}
|
||
|
||
# ========== 新增功能:智能等待 ==========
|
||
|
||
def wait_for_app_ready(self, timeout: float = 5) -> bool:
|
||
"""等待APP完全加载(截图确认界面,间隔 0.2s 轮询)"""
|
||
try:
|
||
start_time = time.time()
|
||
while time.time() - start_time < timeout:
|
||
if self.is_running():
|
||
if not self.d(className="android.widget.ProgressBar").exists(timeout=0.2):
|
||
return True
|
||
time.sleep(0.2)
|
||
return False
|
||
except:
|
||
return True
|
||
|
||
def wait_for_ui(self, hints: list, timeout: float = 3) -> bool:
|
||
"""等待任一 UI 文案出现(界面确认后直接往下)"""
|
||
start = time.time()
|
||
while time.time() - start < timeout:
|
||
for h in hints:
|
||
if self.exists(h, timeout=0.3):
|
||
return True
|
||
time.sleep(0.15)
|
||
return False
|
||
|
||
# ========== 新增功能:截图和OCR ==========
|
||
|
||
def screenshot_to_file(self, filepath: str = None) -> str:
|
||
"""
|
||
截图并保存到文件
|
||
|
||
Args:
|
||
filepath: 保存路径,默认 /sdcard/screenshot_{timestamp}.png
|
||
|
||
Returns:
|
||
文件路径
|
||
"""
|
||
if not filepath:
|
||
filepath = f"/sdcard/screenshot_{int(time.time() * 1000)}.png"
|
||
|
||
self.d.screenshot(filepath)
|
||
return filepath
|
||
|
||
def get_text_from_screen(self, region: Dict[str, int] = None) -> List[str]:
|
||
"""
|
||
从屏幕提取文字(需要OCR)
|
||
|
||
Args:
|
||
region: {"x": 0, "y": 0, "width": 1080, "height": 2400}
|
||
|
||
Returns:
|
||
文字列表
|
||
"""
|
||
# TODO: 集成OCR库(如paddleocr)
|
||
# 暂时返回空列表
|
||
return []
|
||
|
||
# ========== 新增功能:元素查找增强 ==========
|
||
|
||
# ========== 新增功能:错误处理和重试 ==========
|
||
|
||
def retry_operation(self, operation, max_retries: int = 3,
|
||
retry_delay: float = 1.0, **kwargs) -> Any:
|
||
"""
|
||
重试操作
|
||
|
||
Args:
|
||
operation: 操作函数
|
||
max_retries: 最大重试次数
|
||
retry_delay: 重试延迟(秒)
|
||
**kwargs: 传递给操作函数的参数
|
||
|
||
Returns:
|
||
操作结果
|
||
"""
|
||
last_error = None
|
||
|
||
for attempt in range(max_retries):
|
||
try:
|
||
result = operation(**kwargs)
|
||
if result and (not isinstance(result, dict) or result.get("success", True)):
|
||
return result
|
||
# 如果返回失败,继续重试
|
||
except Exception as e:
|
||
last_error = e
|
||
logger.warning(f"操作失败 (尝试 {attempt + 1}/{max_retries}): {e}")
|
||
|
||
if attempt < max_retries - 1:
|
||
self.sleep(retry_delay)
|
||
|
||
# 所有重试都失败
|
||
error_msg = str(last_error) if last_error else "操作失败"
|
||
logger.error(f"操作最终失败: {error_msg}")
|
||
return {"success": False, "error": error_msg}
|
||
|
||
def safe_click(self, selector_type: str, selector_value: str,
|
||
timeout: float = 10, max_retries: int = 2) -> bool:
|
||
"""
|
||
安全点击(带重试)
|
||
|
||
Args:
|
||
selector_type: 选择器类型(text, description, resourceId等)
|
||
selector_value: 选择器值
|
||
timeout: 超时时间
|
||
max_retries: 最大重试次数
|
||
|
||
Returns:
|
||
是否成功
|
||
"""
|
||
def _click():
|
||
if selector_type == "text":
|
||
return self.click_text(selector_value, timeout)
|
||
elif selector_type == "description":
|
||
return self.click_desc(selector_value)
|
||
elif selector_type == "resourceId":
|
||
return self.click_id(selector_value)
|
||
elif selector_type == "textContains":
|
||
return self.click_contains(selector_value, timeout)
|
||
return False
|
||
|
||
result = self.retry_operation(_click, max_retries=max_retries)
|
||
return result if isinstance(result, bool) else result.get("success", False)
|
||
|
||
def safe_input(self, text: str, clear: bool = True, max_retries: int = 2) -> bool:
|
||
"""
|
||
安全输入(带重试)
|
||
|
||
Args:
|
||
text: 输入文本
|
||
clear: 是否清空
|
||
max_retries: 最大重试次数
|
||
|
||
Returns:
|
||
是否成功
|
||
"""
|
||
def _input():
|
||
self.input_text(text, clear=clear)
|
||
return True
|
||
|
||
result = self.retry_operation(_input, max_retries=max_retries)
|
||
return result if isinstance(result, bool) else result.get("success", False)
|
||
|
||
def find_element_by_multiple(self, **kwargs) -> Any:
|
||
"""
|
||
通过多种方式查找元素
|
||
|
||
Args:
|
||
text: 文字
|
||
textContains: 包含文字
|
||
resourceId: 资源ID
|
||
description: content-desc
|
||
className: 类名
|
||
|
||
Returns:
|
||
元素对象或None
|
||
"""
|
||
for key, value in kwargs.items():
|
||
if not value:
|
||
continue
|
||
try:
|
||
if key == "text":
|
||
if self.d(text=value).exists(timeout=1):
|
||
return self.d(text=value)
|
||
elif key == "textContains":
|
||
if self.d(textContains=value).exists(timeout=1):
|
||
return self.d(textContains=value)
|
||
elif key == "resourceId":
|
||
if self.d(resourceId=value).exists(timeout=1):
|
||
return self.d(resourceId=value)
|
||
elif key == "description":
|
||
if self.d(description=value).exists(timeout=1):
|
||
return self.d(description=value)
|
||
elif key == "className":
|
||
if self.d(className=value).exists(timeout=1):
|
||
return self.d(className=value)
|
||
except:
|
||
continue
|
||
return None
|