""" Utility Functions - 动作解析、坐标转换、图像处理 Extracted from GuiAgent/core/utils/common.py for modular use in DroidBot. """ import re import base64 import io import os import logging from typing import Optional, Dict, Any, Tuple, List try: from PIL import Image except ImportError: Image = None logger = logging.getLogger(__name__) # ============================================================================== # 动作解析 # ============================================================================== # 兼容多种参数写法 ACTION_PARAM_PATTERN = re.compile( r"(\w+)\s*[:=]\s*(?:'([^']*)'|\"([^\"]*)\"|`([^`]*)`|\[([^\]]*)\]|(\d+,\d+)|([^\s,]+))" ) def _extract_thought(prediction: str) -> str: """提取 Thought 段落""" match = re.search(r'Thought:\s*(.+?)(?=Action:|$)', prediction, re.DOTALL) return match.group(1).strip() if match else '' def _extract_action_and_params(prediction: str) -> Tuple[str, Dict[str, str]]: """ 提取 Action 类型和参数字符串,并解析成字典 支持格式: 1. Action: click(center='[x, y]') 2. <|begin_of_box|>tap(center='[511, 426]')<|end_of_box|> 3. 长文本后直接跟 Action: tap(center='[596, 359]') Returns: (action_type, inputs) """ action_content = prediction # 提取 <|begin_of_box|>...<|end_of_box|> 中的内容 box_match = re.search(r'<\|begin_of_box\|>(.*?)<\|end_of_box\|>', prediction, re.DOTALL) if box_match: action_content = box_match.group(1).strip() # 定义有效的动作类型列表 valid_actions = ['tap', 'click', 'long_tap', 'drag', 'swipe', 'type', 'key_press', 'wait', 'finished', 'report_stuck_reason', 'receive_email', 'double_click', 'right_click', 'scroll', 'solve_slider_captcha', 'solve_image_captcha', 'login_ios'] action_type: Optional[str] = None params_str: Optional[str] = None # 1) 从 "Action: xxx ..." 行中解析 line_match = re.search(r'Action:\s*(\w+)(.*)', action_content) if line_match: matched_action_type = line_match.group(1) tail = line_match.group(2).strip() if matched_action_type in valid_actions: action_type = matched_action_type # 带括号形式 paren_match = re.match(r'^\(\s*(.*)\s*\)$', tail, re.DOTALL) if paren_match: params_str = paren_match.group(1).strip() else: # 无括号形式 params_str = tail.strip() # 2) 兜底查找 tap(...)/click(...) 等函数调用 if action_type is None: action_pattern = r'\b(' + '|'.join(valid_actions) + r')\s*\(\s*([^)]*?)\s*\)' fallback_match = re.search(action_pattern, action_content, re.DOTALL) if fallback_match: action_type = fallback_match.group(1) params_str = fallback_match.group(2).strip() if action_type is None: return '', {} inputs: Dict[str, str] = {} if params_str: for m in re.finditer(ACTION_PARAM_PATTERN, params_str): key = m.group(1) raw_val = next((g for g in m.groups()[1:] if g is not None), "") # 如果是方括号或逗号分隔的坐标,补回括号 if m.group(5): # 方括号 value = f"[{raw_val}]" elif m.group(6): # 逗号分隔坐标 value = f"[{raw_val}]" else: value = raw_val inputs[key] = value.strip() return action_type, inputs def parse_uitars_action(prediction: str) -> Dict[str, Any]: """ 解析 UITars 风格的动作 输入格式: Thought: ... Action: click(center='[x, y]') Returns: {'action_type': str, 'inputs': dict, 'thought': str} """ action_type, inputs = _extract_action_and_params(prediction) return { 'action_type': action_type, 'inputs': inputs, 'thought': _extract_thought(prediction) } def convert_to_executor_action(parsed: Dict[str, Any]) -> Dict[str, Any]: """ 将解析后的动作转换为执行器格式 保持原始动作名称,不做平台特定的映射。 坐标转换在各 executor 内部完成。 """ action_type = parsed.get('action_type', '') inputs = parsed.get('inputs', {}) # 动作映射(主要处理别名) action_map = { # 通用动作 'type': 'type', 'wait': 'wait', 'finished': 'finished', 'report_stuck_reason': 'report_stuck_reason', 'login_ios': 'login_ios', 'receive_email': 'receive_email', 'key_press': 'key_press', 'hold_key': 'key_press', # 平台特定动作(保持原样) 'click': 'click', 'double_click': 'double_click', 'right_click': 'right_click', 'drag': 'drag', 'scroll': 'scroll', 'tap': 'tap', 'long_tap': 'long_tap', 'swipe': 'swipe', } result = { 'action': action_map.get(action_type, action_type), 'thought': parsed.get('thought', '') } def _parse_center(box_str: str) -> Tuple[Optional[float], Optional[float]]: """解析坐标中心点,并处理归一化坐标兜底""" if not box_str: return None, None try: nums = [float(n.strip()) for n in box_str.strip('[]').split(',') if n.strip()] if len(nums) == 2: x, y = nums[0], nums[1] elif len(nums) >= 4: x1, y1, x2, y2 = nums[0], nums[1], nums[2], nums[3] x, y = (x1 + x2) / 2, (y1 + y2) / 2 else: return None, None # 坐标兜底:如果x和y都小于1,说明LLM返回的是0-1归一化坐标,需要乘以1000 if x < 1 and y < 1: logger.debug(f"[坐标兜底] 检测到0-1归一化坐标 ({x:.4f}, {y:.4f}),乘以1000转换") x = x * 1000 y = y * 1000 return x, y except (ValueError, IndexError): return None, None return None, None # 单点坐标(支持 center 和 point 参数) center_key = 'center' if 'center' in inputs else ('point' if 'point' in inputs else None) if center_key: x, y = _parse_center(inputs[center_key]) if x is not None: result['target'] = [x, y] elif 'start_center' in inputs: x, y = _parse_center(inputs['start_center']) if x is not None: result['target'] = [x, y] # 拖拽终点 if 'end_center' in inputs: ex, ey = _parse_center(inputs['end_center']) if ex is not None: result['start'] = result.get('target') result['end'] = [ex, ey] # 文本 if 'content' in inputs: result['text'] = inputs['content'] elif 'value' in inputs: result['text'] = inputs['value'] # 按键 if 'key' in inputs: result['key'] = inputs['key'] # 方向 if 'direction' in inputs: result['direction'] = inputs['direction'] # 滑动幅度 if 'amount' in inputs: try: result['amount'] = float(inputs['amount']) except (ValueError, TypeError): pass # 验证码技能参数 if 'captcha_region' in inputs: # 解析 [x1, y1, x2, y2] 格式 try: region_str = inputs['captcha_region'].strip('[]') region_nums = [float(n.strip()) for n in region_str.split(',') if n.strip()] if len(region_nums) == 4: result['captcha_region'] = region_nums except (ValueError, IndexError): pass if 'slider_position' in inputs: try: pos_str = inputs['slider_position'].strip('[]') pos_nums = [float(n.strip()) for n in pos_str.split(',') if n.strip()] if len(pos_nums) == 2: result['slider_position'] = pos_nums except (ValueError, IndexError): pass if 'input_field' in inputs: try: field_str = inputs['input_field'].strip('[]') field_nums = [float(n.strip()) for n in field_str.split(',') if n.strip()] if len(field_nums) == 2: result['input_field'] = field_nums except (ValueError, IndexError): pass # report_stuck_reason 参数 if 'reason_code' in inputs: result['reason_code'] = inputs['reason_code'].strip() if 'message' in inputs: result['message'] = inputs['message'].strip() return result # ============================================================================== # 图像处理 # ============================================================================== def draw_grid_on_image(image, grid_color: Tuple[int, int, int, int] = (255, 0, 0, 128), grid_divisions: int = 10, coord_range: Tuple[int, int] = None): """ 在图像上绘制网格线并标注坐标数字,帮助LLM精确理解坐标位置 Args: image: PIL Image对象 grid_color: 网格线颜色 (R, G, B, A),默认为红色半透明 grid_divisions: 网格分割数量,默认为10,即绘制10x10的网格 coord_range: 坐标范围 (max_x, max_y),默认为 (1000, 1000) 即归一化坐标 如果为None则使用图像实际像素尺寸 Returns: 绘制了网格和坐标标注的图像 """ if Image is None: logger.warning("PIL未安装,无法绘制网格") return image try: from PIL import ImageDraw, ImageFont # 创建一个可以绘制的副本 img_copy = image.copy() # 获取图像尺寸 width, height = img_copy.size # 确定坐标范围 if coord_range is None: coord_max_x, coord_max_y = width, height else: coord_max_x, coord_max_y = coord_range # 计算网格大小(像素) grid_width = width // grid_divisions grid_height = height // grid_divisions draw = ImageDraw.Draw(img_copy) # 尝试加载字体,动态计算合适的字体大小 font_size = max(10, min(width, height) // 40) # 依次尝试 macOS / Windows / Linux 字体路径 _font_candidates = [ "/System/Library/Fonts/Helvetica.ttc", # macOS "C:/Windows/Fonts/arial.ttf", # Windows "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Linux (Debian/Ubuntu) ] font = None for _font_path in _font_candidates: try: font = ImageFont.truetype(_font_path, font_size) break except Exception: continue if font is None: font = ImageFont.load_default() # 标注颜色(与网格线同色但更不透明) label_color = (grid_color[0], grid_color[1], grid_color[2], min(255, grid_color[3] + 80)) # 标注背景色(半透明白色,提高可读性) bg_color = (255, 255, 255, 160) # 绘制垂直线 + 顶部X坐标标注 for i in range(1, grid_divisions): x = i * grid_width draw.line([(x, 0), (x, height)], fill=grid_color, width=1) # 计算对应的坐标值 coord_x = int(i * coord_max_x / grid_divisions) label = str(coord_x) # 获取文本尺寸 bbox = draw.textbbox((0, 0), label, font=font) text_w = bbox[2] - bbox[0] text_h = bbox[3] - bbox[1] # 在网格线顶部标注(带背景) label_x = x - text_w // 2 label_y = 2 draw.rectangle([label_x - 1, label_y, label_x + text_w + 1, label_y + text_h + 1], fill=bg_color) draw.text((label_x, label_y), label, fill=label_color, font=font) # 绘制水平线 + 左侧Y坐标标注 for i in range(1, grid_divisions): y = i * grid_height draw.line([(0, y), (width, y)], fill=grid_color, width=1) # 计算对应的坐标值 coord_y = int(i * coord_max_y / grid_divisions) label = str(coord_y) # 获取文本尺寸 bbox = draw.textbbox((0, 0), label, font=font) text_w = bbox[2] - bbox[0] text_h = bbox[3] - bbox[1] # 在网格线左侧标注(带背景) label_x = 2 label_y = y - text_h // 2 draw.rectangle([label_x - 1, label_y, label_x + text_w + 1, label_y + text_h + 1], fill=bg_color) draw.text((label_x, label_y), label, fill=label_color, font=font) logger.debug(f"在图像上绘制了 {grid_divisions}x{grid_divisions} 网格(坐标范围: {coord_max_x}x{coord_max_y})") return img_copy except Exception as e: logger.error(f"绘制网格失败: {e}") return image def draw_last_action_marker(image, x: int, y: int, color: Tuple[int, int, int, int] = (0, 255, 0, 200), radius: int = 5, width: int = 3): """ 在截图上绘制上次动作位置的绿色圆圈标记 Args: image: PIL Image对象 x: 标记的x坐标(绝对像素坐标) y: 标记的y坐标(绝对像素坐标) color: 圆圈颜色 (R, G, B, A),默认绿色 radius: 圆圈半径,默认5像素 width: 圆圈线宽,默认3像素 Returns: 绘制了标记的图像 """ if Image is None: logger.warning("PIL未安装,无法绘制标记") return image try: from PIL import ImageDraw, ImageFont img_copy = image.copy() draw = ImageDraw.Draw(img_copy) # 绘制空心圆圈 bbox = [x - radius, y - radius, x + radius, y + radius] draw.ellipse(bbox, outline=color, width=width) # 绘制坐标数字 draw.text((x, y), f"({x}, {y})", fill=color, font=ImageFont.load_default()) logger.debug(f"在截图上绘制了上次动作标记: ({x}, {y})") return img_copy except Exception as e: logger.error(f"绘制动作标记失败: {e}") return image def image_to_base64(image) -> str: """ 将 PIL 图像转换为 Base64 编码字符串 Args: image: PIL Image对象 Returns: Base64 编码字符串 """ if Image is None: return "" try: img_byte_arr = io.BytesIO() image.save(img_byte_arr, format='PNG') img_byte_arr = img_byte_arr.getvalue() return base64.b64encode(img_byte_arr).decode('utf-8') except Exception as e: logger.error(f"图像转Base64失败: {e}") return "" def get_image_size(image_base64: str) -> Tuple[int, int]: """获取图像尺寸""" if Image is None: return 0, 0 try: clean = re.sub(r'^data:image/\w+;base64,', '', image_base64) img = Image.open(io.BytesIO(base64.b64decode(clean))) return img.size except Exception: return 0, 0 def strip_base64_prefix(base64_str: str) -> str: """移除 Base64 的 data URI 前缀""" return re.sub(r'^data:image/\w+;base64,', '', base64_str) # ============================================================================== # Gmail 邮件检查器 (从 GuiAgent/core/utils/gmail_checker.py 迁移) # ============================================================================== class GmailChecker: """ Gmail 邮件检查器,用于 receive_email 动作的邮件获取 需要 Google API 相关依赖: - google-auth-oauthlib - google-api-python-client """ SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"] def __init__(self, credentials_path: str = None, token_path: str = None): base_dir = os.path.dirname(os.path.abspath(__file__)) self.credentials_path = credentials_path or os.path.join(base_dir, "credentials.json") self.token_path = token_path or os.path.join(base_dir, "token.json") self.creds = None self.service = None self._initialized = False def _ensure_initialized(self) -> bool: """延迟初始化 Gmail 服务""" if self._initialized: return self.service is not None self._initialized = True try: import os.path from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow from googleapiclient.discovery import build except ImportError as e: logger.warning(f"Gmail API 依赖未安装: {e}") return False try: # 加载存储的凭证 if os.path.exists(self.token_path): self.creds = Credentials.from_authorized_user_file(self.token_path, self.SCOPES) # 如果没有有效凭证,让用户登录 if not self.creds or not self.creds.valid: if self.creds and self.creds.expired and self.creds.refresh_token: self.creds.refresh(Request()) else: flow = InstalledAppFlow.from_client_secrets_file( self.credentials_path, self.SCOPES ) self.creds = flow.run_local_server(port=0) # 保存凭证以便下次使用 with open(self.token_path, "w") as token: token.write(self.creds.to_json()) # 创建 Gmail 服务 self.service = build("gmail", "v1", credentials=self.creds) logger.info("Gmail 服务初始化成功") return True except Exception as e: logger.error(f"Gmail 服务初始化失败: {e}") return False def get_email_content(self, message_id: str) -> Optional[Dict[str, Any]]: """根据邮件ID获取邮件内容""" if not self._ensure_initialized(): return None try: from googleapiclient.errors import HttpError message = self.service.users().messages().get( userId="me", id=message_id, format="full" ).execute() # 提取邮件头信息 headers = message["payload"]["headers"] subject = "" sender = "" date = "" for header in headers: if header["name"] == "Subject": subject = header["value"] elif header["name"] == "From": sender = header["value"] elif header["name"] == "Date": date = header["value"] # 提取邮件正文 def extract_message_text(part): if "parts" in part: for subpart in part["parts"]: text = extract_message_text(subpart) if text: return text elif part["mimeType"] == "text/plain": if "data" in part["body"]: return base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8") elif part["mimeType"] == "text/html": if "data" in part["body"]: html_content = base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8") return f"[HTML内容]\n{html_content}" return "" message_text = extract_message_text(message["payload"]) # 过滤空行 if message_text: lines = message_text.splitlines() filtered_lines = [line for line in lines if line.strip()] message_text = '\n'.join(filtered_lines) return { "id": message_id, "sender": sender, "subject": subject, "date": date, "content": message_text } except Exception as e: logger.error(f"获取邮件内容时出错: {e}") return None def is_email_within_timeframe(self, message: Dict, minutes: int = 2) -> bool: """判断邮件是否在指定时间范围内""" import time as time_module try: internal_date = int(message.get('internalDate', 0)) / 1000 current_time = time_module.time() time_diff_minutes = (current_time - internal_date) / 60 return time_diff_minutes <= minutes except Exception as e: logger.warning(f"判断邮件时间时出错: {e}") return False def wait_for_new_email( self, polling_interval: int = 10, time_window: int = 2, max_retries: int = 10 ) -> List[Dict[str, Any]]: """ 阻塞等待新邮件,直到收到指定时间窗口内的新邮件后返回所有符合条件的邮件 Args: polling_interval: 检查新邮件的时间间隔(秒) time_window: 新邮件的时间窗口(分钟),默认2分钟 max_retries: 最大重试次数 Returns: 符合条件的邮件列表,每封邮件包含 id、sender、subject、date 和 content 字段 """ import time as time_module if not self._ensure_initialized(): logger.error("Gmail 服务未初始化,无法等待邮件") return [] logger.info(f"开始监听新邮件,检查间隔: {polling_interval}秒,时间窗口: {time_window}分钟") retry_count = 0 while retry_count < max_retries: try: # 检查是否需要刷新凭证 from google.auth.transport.requests import Request if self.creds and self.creds.expired and self.creds.refresh_token: self.creds.refresh(Request()) with open(self.token_path, "w") as token: token.write(self.creds.to_json()) # 计算时间窗口的起始时间戳 current_timestamp = int(time_module.time()) after_timestamp = current_timestamp - (time_window * 60) query = f"after:{after_timestamp}" # 获取最新的一批邮件,使用 q 参数过滤时间 results = self.service.users().messages().list(userId="me", maxResults=10, q=query).execute() messages = results.get("messages", []) valid_emails = [] if messages: for msg_summary in messages: message_id = msg_summary["id"] message = self.service.users().messages().get( userId="me", id=message_id, format="metadata" ).execute() if self.is_email_within_timeframe(message, time_window): email_content = self.get_email_content(message_id) if email_content: valid_emails.append(email_content) else: # 假设邮件按时间倒序,遇到超时的可以停止检查 # 但为了保险,可以根据具体需求决定是否 break。 # 这里简单起见不 break,检查完前10封。 pass if valid_emails: logger.info(f"收到 {len(valid_emails)} 封 {time_window} 分钟内的邮件") return valid_emails else: logger.debug(f"最近的邮件都不在 {time_window} 分钟时间窗口内,继续等待...") else: logger.debug("没有找到邮件,继续等待...") retry_count += 1 time_module.sleep(polling_interval) except Exception as e: logger.error(f"监听邮件时出错: {e}") retry_count += 1 time_module.sleep(polling_interval) logger.warning(f"已重试 {max_retries} 次,未收到新邮件") return [] def receive_email( token_path: str = "token.json", credentials_path: str = "credentials.json", polling_interval: int = 10, time_window: int = 2, max_retries: int = 10 ) -> List[Dict[str, Any]]: """ receive_email 动作的便捷函数 Args: token_path: Gmail token 文件路径 credentials_path: Gmail credentials 文件路径 polling_interval: 检查间隔(秒) time_window: 时间窗口(分钟) max_retries: 最大重试次数 Returns: 邮件内容列表 """ logger.info("开始接收新邮件") try: gmail_checker = GmailChecker( token_path=token_path, credentials_path=credentials_path ) new_emails = gmail_checker.wait_for_new_email( polling_interval=polling_interval, time_window=time_window, max_retries=max_retries ) return new_emails if new_emails else [] except Exception as e: logger.error(f"执行 receive_email 失败: {e}") return [] def login_ios(device) -> tuple: """ iOS Apple ID 密码登录脚本 在 agent 识别到系统弹出的 Apple ID 登录对话框时调用, 通过 WDA 直接操作完成密码输入和登录,避免 agent 操作系统级 UI 出错。 完整流程:从点击"通过密码登录"按钮开始,覆盖不同页面调用的场景。 Args: device: IOSDevice 实例,需要有 _wda_client 属性 Returns: (success: bool, message: str) """ import time as time_module APPLE_PASSWORD = "tsIcmustyy1" # 获取 WDA 客户端 wda_client = getattr(device, '_wda_client', None) if wda_client is None: logger.error("[login_ios] 无法获取 WDA 客户端") return False, "无法获取 WDA 客户端" try: # === 第1步:查找并点击"通过密码登录"相关按钮 === # 覆盖中英文多种表述,确保从不同页面调用都能处理 password_login_labels = [ "通过密码登录", "用密码登录", "使用密码登录", "密码登录", "Use Password", "Sign In with Password", "Use Password…", "Use Password...", ] clicked_password_btn = False for label in password_login_labels: try: if wda_client(label=label).click_exists(timeout=1.0): logger.info(f"[login_ios] 点击了密码登录按钮: {label}") clicked_password_btn = True time_module.sleep(1.5) # 等待密码输入界面加载 break except Exception as e: logger.debug(f"[login_ios] 查找按钮 '{label}' 异常: {e}") continue if not clicked_password_btn: # 可能已经在密码输入页面,尝试直接查找密码输入框 logger.info("[login_ios] 未找到密码登录按钮,尝试直接查找密码输入框") # === 第2步:查找并操作密码输入框 === password_field = None # 尝试多次查找,等待 UI 加载 for attempt in range(5): try: # SecureTextField 是 iOS 密码输入框的控件类型 secure_fields = wda_client(type='SecureTextField') if secure_fields.exists: password_field = secure_fields logger.info(f"[login_ios] 找到密码输入框 (尝试 {attempt + 1})") break except Exception as e: logger.debug(f"[login_ios] 查找密码输入框异常 (尝试 {attempt + 1}): {e}") time_module.sleep(1.0) if password_field is None: logger.warning("[login_ios] 未找到密码输入框") return False, "未找到密码输入框,可能当前页面不在密码登录界面" # 点击密码输入框使其获得焦点 try: password_field.tap() time_module.sleep(0.5) except Exception as e: logger.warning(f"[login_ios] 点击密码输入框失败: {e}") # 清除已有内容(避免重复输入导致密码错误) try: current_value = password_field.get().value if current_value: # 全选并删除 password_field.clear_text() time_module.sleep(0.3) logger.debug("[login_ios] 已清除密码输入框内容") except Exception as e: logger.debug(f"[login_ios] 清除内容时异常(可继续): {e}") # 输入密码 try: password_field.set_text(APPLE_PASSWORD) time_module.sleep(0.5) logger.info("[login_ios] 密码输入完成") except Exception as e: logger.error(f"[login_ios] 密码输入失败: {e}") return False, f"密码输入失败: {e}" # === 第3步:点击登录按钮 === sign_in_labels = [ "登录", "Sign In", "sign in", "登入", "确定", "OK", ] clicked_sign_in = False for label in sign_in_labels: try: if wda_client(label=label).click_exists(timeout=1.0): logger.info(f"[login_ios] 点击了登录按钮: {label}") clicked_sign_in = True break except Exception as e: logger.debug(f"[login_ios] 查找登录按钮 '{label}' 异常: {e}") continue if not clicked_sign_in: # 尝试通过 type=Button 查找登录按钮 try: for label in sign_in_labels: if wda_client(label=label, type='Button').click_exists(timeout=1.0): logger.info(f"[login_ios] 通过 Button 类型点击登录: {label}") clicked_sign_in = True break except Exception as e: logger.debug(f"[login_ios] Button 类型查找异常: {e}") if not clicked_sign_in: logger.warning("[login_ios] 未找到登录按钮,密码已输入但无法提交") return False, "密码已输入但未找到登录按钮" # === 第4步:等待并检查登录结果 === time_module.sleep(3) # 等待登录处理 # 检查密码输入框是否已消失(消失说明登录成功或进入下一步) try: if not wda_client(type='SecureTextField').exists: logger.info("[login_ios] 登录成功:密码输入框已消失") return True, "Apple ID 密码登录成功" else: # 密码框仍存在,可能密码错误 logger.warning("[login_ios] 密码通过后输入框仍存在,可能登录需要额外信息填写") return False, "登录可能失败:密码通过后输入框仍存在,可能登录需要额外信息填写" except Exception as e: logger.debug(f"[login_ios] 检查登录结果异常: {e}") # 异常时保守认为成功(可能页面已跳转导致元素不存在) return True, "登录操作已执行(无法确认结果)" except Exception as e: logger.error(f"[login_ios] 执行异常: {e}") return False, f"登录脚本异常: {e}" if __name__ == "__main__": new_emails = receive_email() if new_emails: for email in new_emails: print(email) else: print("没有新邮件")