""" 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