diff --git a/.gitignore b/.gitignore index 36b13f1..ef7a511 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,15 @@ -# ---> Python # Byte-compiled / optimized / DLL files __pycache__/ +# Python bytecode & caches +*.pyc +*.pyo +*.pyd *.py[cod] *$py.class - -# C extensions -*.so +build.bat # Distribution / packaging .Python -build/ develop-eggs/ dist/ downloads/ @@ -20,12 +20,12 @@ lib64/ parts/ sdist/ var/ -wheels/ -share/python-wheels/ *.egg-info/ .installed.cfg *.egg -MANIFEST +out/ +Main.build/ +Main.dist/ # PyInstaller # Usually these files are written by a python script from a template @@ -47,10 +47,8 @@ htmlcov/ nosetests.xml coverage.xml *.cover -*.py,cover .hypothesis/ .pytest_cache/ -cover/ # Translations *.mo @@ -71,9 +69,9 @@ instance/ # Sphinx documentation docs/_build/ +docs/.doctrees/ # PyBuilder -.pybuilder/ target/ # Jupyter Notebook @@ -84,9 +82,7 @@ profile_default/ ipython_config.py # pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version +.python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. @@ -95,35 +91,8 @@ ipython_config.py # install all needed dependencies. #Pipfile.lock -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff +# celery beat schedule file celerybeat-schedule -celerybeat.pid # SageMath parsed files *.sage.py @@ -155,22 +124,9 @@ dmypy.json # Pyre type checker .pyre/ -# pytype static type analyzer +# pytype static type checker .pytype/ # Cython debug symbols cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - +build-tidevice.bat diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..10b731c --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/.idea/IOS__AI.iml b/.idea/IOS__AI.iml new file mode 100644 index 0000000..ec63674 --- /dev/null +++ b/.idea/IOS__AI.iml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/git_toolbox_blame.xml b/.idea/git_toolbox_blame.xml new file mode 100644 index 0000000..7dc1249 --- /dev/null +++ b/.idea/git_toolbox_blame.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/iOSAI.iml b/.idea/iOSAI.iml new file mode 100644 index 0000000..8b8c395 --- /dev/null +++ b/.idea/iOSAI.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..6eda094 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,22 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..a37b124 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..bd6dec5 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Entity/AnchorModel.py b/Entity/AnchorModel.py new file mode 100644 index 0000000..8731735 --- /dev/null +++ b/Entity/AnchorModel.py @@ -0,0 +1,21 @@ + +# 主播模型 +class AnchorModel: + def __init__(self, anchorId= "", country= ""): + # 主播ID + self.anchorId = anchorId + # 主播国家 + self.country = country + + # 字典转模型 + @classmethod + def dictToModel(cls, d): + model = AnchorModel() + model.anchorId = d.get('anchorId', "") + model.country = d.get('country', "") + return model + + # 模型转字典 + @classmethod + def modelToDict(cls, model): + return {"anchorId": model.anchorId, "country": model.country} diff --git a/Entity/DeviceModel.py b/Entity/DeviceModel.py new file mode 100644 index 0000000..8720e70 --- /dev/null +++ b/Entity/DeviceModel.py @@ -0,0 +1,30 @@ + +# 设备模型 +class DeviceModel(object): + def __init__(self, deviceId, screenPort, width, height, scale, type): + super(DeviceModel, self).__init__() + # 设备id + self.deviceId = deviceId + # 投屏端口 + self.screenPort = screenPort + # 屏幕宽度 + self.width = width + # 屏幕高度 + self.height = height + # 物理分辨率和实际分辨率的倍数 + self.scale = scale + # 1 添加 2删除 + self.type = type + self.ready = False + self.deleting = False + + # 转字典 + def toDict(self): + return { + 'deviceId': self.deviceId, + 'screenPort': self.screenPort, + "width": self.width, + "height": self.height, + "scale": self.scale, + 'type': self.type + } \ No newline at end of file diff --git a/Entity/ResultData.py b/Entity/ResultData.py new file mode 100644 index 0000000..cf50762 --- /dev/null +++ b/Entity/ResultData.py @@ -0,0 +1,16 @@ +import json + +# 返回数据模型 +class ResultData(object): + def __init__(self, code=200, data=None, message="获取成功"): + super(ResultData, self).__init__() + self.code = code + self.data = data + self.message = message + + def toJson(self): + return json.dumps({ + "code": self.code, + "data": self.data, + "message": self.message + }, ensure_ascii=False) # ensure_ascii=False 确保中文不会被转义 \ No newline at end of file diff --git a/Entity/Variables.py b/Entity/Variables.py new file mode 100644 index 0000000..a8f9429 --- /dev/null +++ b/Entity/Variables.py @@ -0,0 +1,70 @@ +import threading +from typing import Dict, Any +from Entity.AnchorModel import AnchorModel + +# wda apple bundle id +WdaAppBundleId = "com.yolojtAgent.wda.xctrunner" +# WdaAppBundleId = "com.yolozsAgent.wda.xctrunner" +# wda投屏端口 +wdaScreenPort = 9567 +# wda功能端口 +wdaFunctionPort = 8567 +# 全局主播列表 +anchorList: list[AnchorModel] = [] +# 线程锁 +anchorListLock = threading.Lock() +# 打招呼数据 +prologueList = {} + +# 评论数据 +commentList = [] + +API_KEY = "app-sdRfZy2by9Kq7uJg7JdOSVr8" + +# 本地储存的打招呼数据 +localPrologueList = [ + "If you are interested in this, you can join our team for a period of time. During this period, if you like our team, you can continue to stay in our team. If you don't like it, you can leave at any time, and you won't lose anything!", + "What's even better is that after joining our team, you have no obligations and you don't have to pay me. Everything is free", + "I'm from the MARS agency. I noticed that you're very active on TikTok, so I contacted you and hope to become your agent❤", + "Hello, can we talk about cooperation and support?", + "Hello, I’m a supporter. Can we have a chat?", + "Hi 👋 I’m an official TikTok partner. I really enjoy your livestreams. Do you have time to chat about support and cooperation?", + "Hello, I really like your livestreams. I’d love to talk about cooperation and also support you.", + "Hello, I think you have the potential to become a top streamer. Can we talk?", + "Nice to meet you 😊 I watched your livestream and really enjoyed it. Can we chat about cooperation?", + "Hello 👋 I’m a livestream team manager. I recently watched your livestream—it’s great 👍 I hope we can talk about how to grow together 💪", + "I watched your livestream and would like to invite you to join our team. I’ll reward you with more gifts based on your performance.", + "Hello, I’d like to promote your livestream. Can we talk?", + "Hello, I think I can help you get more gifts and support for free. Would you be interested in talking with me?", + "Hello, I really enjoyed your livestream. Can we chat about cooperation?" +] + +# 评论列表 +commentsList = [] +# 存储主播名和session_id的字典 +anchorWithSession = {} +# 前端传递的token +token = '' +# 前端传递的 +tenantId = 0 + +userId = 0 + + +# 安全删除数据 +def removeModelFromAnchorList(model: AnchorModel): + with anchorListLock: + anchorList.remove(model) + + +# 添加数据 +def addModelToAnchorList(models: list[Dict[str, Any]]): + with anchorListLock: + for dic in models: + obj = AnchorModel.dictToModel(dic) + anchorList.append(obj) + + +# 添加打招呼语 +def addDataToPrologue(data: list[str]): + prologueList = data diff --git a/Module/DeviceInfo.py b/Module/DeviceInfo.py new file mode 100644 index 0000000..02b2f16 --- /dev/null +++ b/Module/DeviceInfo.py @@ -0,0 +1,513 @@ +import json +import os +import socket +import threading +import time +import subprocess +from typing import Dict +import tidevice +import wda +from tidevice import Usbmux, ConnectionType +from Entity.DeviceModel import DeviceModel +from Entity.Variables import WdaAppBundleId, wdaFunctionPort +from Module.FlaskSubprocessManager import FlaskSubprocessManager +from Module.IOSActivator import IOSActivator +from Utils.LogManager import LogManager + + +class DeviceInfo: + _instance = None + _instance_lock = threading.Lock() + + # 离线宽限期(保持你原来的数值) + REMOVE_GRACE_SEC = 5.0 + + def __new__(cls, *args, **kwargs): + if not cls._instance: + with cls._instance_lock: + if not cls._instance: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self) -> None: + if getattr(self, "_initialized", False): + return + + self._lock = threading.RLock() + self._models: Dict[str, DeviceModel] = {} + self._manager = FlaskSubprocessManager.get_instance() + self.screenPort = 9110 + + # 设备心跳时间 + self._last_seen: Dict[str, float] = {} + + # iproxy 子进程:udid -> Popen + self._iproxy_process: Dict[str, subprocess.Popen] = {} + + # iproxy HTTP 健康检查失败次数:udid -> 连续失败次数 + self._iproxy_fail_count: Dict[str, int] = {} + + # Windows 下隐藏子进程窗口(给 iproxy 用) + self._creationflags = 0 + self._startupinfo = None + if os.name == "nt": + try: + # type: ignore[attr-defined] + self._creationflags = subprocess.CREATE_NO_WINDOW + except Exception: + self._creationflags = 0 + + si = subprocess.STARTUPINFO() + si.dwFlags |= subprocess.STARTF_USESHOWWINDOW + si.wShowWindow = 0 # SW_HIDE + self._startupinfo = si + + LogManager.info("DeviceInfo 初始化完成", udid="system") + print("[Init] DeviceInfo 初始化完成") + self._initialized = True + + # ========================== + # 主循环 + # ========================== + def listen(self): + LogManager.method_info("进入主循环", "listen", udid="system") + print("[Listen] 开始监听设备上下线...") + + while True: + try: + usb = Usbmux().device_list() + # 只看 USB 连接的设备 + online = {d.udid for d in usb if d.conn_type == ConnectionType.USB} + except Exception as e: + LogManager.warning(f"[device_list] 异常:{e}", udid="system") + time.sleep(1) + continue + + now = time.time() + + # 当前已知的设备(本轮循环开始时) + with self._lock: + known = set(self._models.keys()) + current_count = len(self._models) + + # 1. 处理在线设备 + for udid in online: + # 更新心跳时间 + self._last_seen[udid] = now + + # 新设备但数量已达上限 + if udid not in known and current_count >= 6: + print(f"[Add] 设备数量已达 6 台,忽略新设备: {udid}") + LogManager.info( + "[Add] 设备数量已达上限(6),忽略新设备", + udid=udid, + ) + continue + + # 已经在列表里的设备,跳过添加流程 + if udid in known: + continue + + # 只对新发现的设备做一次信任检查 + try: + if not self._is_trusted(udid): + LogManager.info( + "[Add] 设备未信任或未就绪,跳过本轮添加", + udid=udid, + ) + print(f"[Add] 设备未信任或未就绪,跳过: {udid}") + continue + except Exception as e: + LogManager.warning( + f"[Add] 检测设备 {udid} 信任状态异常: {e}", + udid=udid, + ) + print(f"[Add] 检测设备 {udid} 信任状态异常: {e}") + continue + + # 二次确认数量上限 + with self._lock: + if len(self._models) >= 6: + print(f"[Add] 二次检查: 设备数量已达 6 台,忽略新设备: {udid}") + LogManager.info( + "[Add] 二次检查数量上限,忽略新设备", + udid=udid, + ) + continue + + # 真正添加设备 + try: + self._add_device(udid) + current_count += 1 + except Exception as e: + LogManager.warning( + f"[Add] 处理设备 {udid} 异常: {e}", + udid=udid, + ) + print(f"[Add] 处理设备 {udid} 异常: {e}") + + # 2. 处理可能离线的设备(只看本轮开始时 known 里的) + for udid in list(known): + if udid not in online: + last = self._last_seen.get(udid, 0) + if now - last > self.REMOVE_GRACE_SEC: + try: + self._remove_device(udid) + except Exception as e: + LogManager.method_error( + f"移除失败:{e}", + "listen", + udid=udid, + ) + print(f"[Remove] 移除失败 {udid}: {e}") + + # 3. iproxy 看门狗(进程 + HTTP 探活) + try: + self._check_iproxy_health() + except Exception as e: + LogManager.warning( + f"[iproxy] 看门狗异常: {e}", + udid="system", + ) + print(f"[iproxy] 看门狗异常: {e}") + + time.sleep(1) + + # 判断设备是否信任 + def _is_trusted(self, udid: str) -> bool: + try: + d = tidevice.Device(udid) + _ = d.product_version + return True + except Exception as e: + msg = str(e) + if "NotTrusted" in msg or "Please trust" in msg or "InvalidHostID" in msg: + print(f"[Trust] 设备未信任,udid={udid}, err={msg}") + return False + + print(f"[Trust] 检测信任状态出错,当作未信任处理 udid={udid}, err={msg}") + return False + + # ========================== + # 添加设备 + # ========================== + def _add_device(self, udid: str): + with self._lock: + if udid in self._models: + print(f"[Add] 已存在,跳过: {udid}") + return + print(f"[Add] 新增设备 {udid}") + + # 判断 iOS 版本 + try: + t = tidevice.Device(udid) + version_major = float(t.product_version.split(".")[0]) + except Exception as e: + print(f"[Add] 获取系统版本失败 {udid}: {e}") + version_major = 0 + + # 分配投屏端口 & 写入模型 + with self._lock: + self.screenPort += 1 + screen_port = self.screenPort + + model = DeviceModel( + deviceId=udid, + screenPort=screen_port, + width=0, + height=0, + scale=0, + type=1, + ) + self._models[udid] = model + + print(f"[Add] 新设备完成 {udid}, screenPort={screen_port}") + self._manager_send() + + # 启动 iproxy(投屏转发) + try: + self._start_iproxy(udid, screen_port) + except Exception as e: + print(f"[iproxy] 启动失败 {udid}: {e}") + LogManager.warning(f"[iproxy] 启动失败: {e}", udid=udid) + + # 启动 WDA + if version_major >= 17.0: + threading.Thread( + target=IOSActivator().activate_ios17, + args=(udid, self._on_wda_ready), + daemon=True, + ).start() + else: + try: + tidevice.Device(udid).app_start(WdaAppBundleId) + except Exception as e: + print(f"[Add] 使用 tidevice 启动 WDA 失败 {udid}: {e}") + LogManager.warning( + f"[Add] 使用 tidevice 启动 WDA 失败: {e}", + udid=udid, + ) + else: + threading.Thread( + target=self._fetch_screen_and_notify, + args=(udid,), + daemon=True, + ).start() + + # ========================== + # WDA 启动回调(iOS17+) + # ========================== + def _on_wda_ready(self, udid: str): + print(f"[WDA] 回调触发,准备获取屏幕信息 udid={udid}") + time.sleep(1) + threading.Thread( + target=self._fetch_screen_and_notify, + args=(udid,), + daemon=True, + ).start() + + # ========================== + # 通过 WDA 获取屏幕信息 + # ========================== + def _screen_info(self, udid: str): + try: + c = wda.USBClient(udid, wdaFunctionPort) + size = c.window_size() + + w = int(size.width) + h = int(size.height) + s = float(c.scale) + + print(f"[Screen] 成功获取屏幕 {w}x{h} scale={s} {udid}") + return w, h, s + except Exception as e: + print(f"[Screen] 获取屏幕失败: {e} udid={udid}") + return 0, 0, 0.0 + + # ========================== + # 异步获取屏幕尺寸并通知 Flask + # ========================== + def _fetch_screen_and_notify(self, udid: str): + """ + 后台线程里多次尝试通过 WDA 获取屏幕尺寸, + 成功后更新 model 并发一次 snapshot。 + """ + max_retry = 15 + interval = 1.0 + + time.sleep(2.0) + for _ in range(max_retry): + with self._lock: + if udid not in self._models: + print(f"[Screen] 设备已移除,停止获取屏幕信息 udid={udid}") + return + + w, h, s = self._screen_info(udid) + if w > 0 and h > 0: + with self._lock: + m = self._models.get(udid) + if not m: + print(f"[Screen] 模型已不存在,无法更新 udid={udid}") + return + m.width = w + m.height = h + m.scale = s + + print(f"[Screen] 屏幕信息更新完成,准备推送到 Flask udid={udid}") + try: + self._manager_send() + except Exception as e: + print(f"[Screen] 发送屏幕更新到 Flask 失败 udid={udid}, err={e}") + return + + time.sleep(interval) + + print(f"[Screen] 多次尝试仍未获取到屏幕信息 udid={udid}") + + # ========================== + # iproxy 管理 + # ========================== + def _start_iproxy(self, udid: str, local_port: int): + iproxy_path = self._find_iproxy() + + p = self._iproxy_process.get(udid) + if p is not None and p.poll() is None: + print(f"[iproxy] 已存在运行中的进程,跳过 {udid}") + return + + args = [ + iproxy_path, + "-u", + udid, + str(local_port), # 本地端口(投屏) + "9567", # 手机端口(go-ios screencast) + ] + + print(f"[iproxy] 启动进程: {args}") + + proc = subprocess.Popen( + args, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=self._creationflags, + startupinfo=self._startupinfo, + ) + + self._iproxy_process[udid] = proc + + def _stop_iproxy(self, udid: str): + p = self._iproxy_process.get(udid) + if not p: + return + try: + p.terminate() + try: + p.wait(timeout=2) + except Exception: + p.kill() + except Exception: + pass + self._iproxy_process.pop(udid, None) + print(f"[iproxy] 已停止 {udid}") + + def _is_iproxy_http_healthy(self, local_port: int, timeout: float = 1.0) -> bool: + """ + 通过向本地 iproxy 转发端口发一个最小的 HTTP 请求, + 来判断隧道是否“活着”: + - 正常:能在超时时间内读到一些 HTTP 头 / 任意字节; + - 异常:连接失败、超时、完全收不到字节,都认为不健康。 + """ + try: + with socket.create_connection(("127.0.0.1", local_port), timeout=timeout) as s: + s.settimeout(timeout) + + req = b"GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n" + s.sendall(req) + + data = s.recv(128) + if not data: + return False + + if data.startswith(b"HTTP/") or b"\r\n" in data: + return True + + # 即使不是标准 HTTP 头,只要有返回字节,也说明隧道有响应 + return True + + except (socket.timeout, OSError): + return False + except Exception: + return False + + def _check_iproxy_health(self): + """ + iproxy 看门狗: + - 先看进程是否存在 / 已退出; + - 再做一次 HTTP 层探活; + - 连续多次失败才重启,避免抖动时频繁重启。 + """ + with self._lock: + items = list(self._models.items()) + + for udid, model in items: + proc = self._iproxy_process.get(udid) + + # 1) 进程不存在或已退出:直接重启 + if proc is None or proc.poll() is not None: + msg = f"[iproxy] 进程已退出,准备重启 | udid={udid}" + print(msg) + LogManager.warning(msg, "iproxy") + + self._iproxy_fail_count[udid] = 0 + try: + self._start_iproxy(udid, model.screenPort) + except Exception as e: + msg = f"[iproxy] 重启失败 | udid={udid} | err={e}" + print(msg) + LogManager.warning(msg, "iproxy") + continue + + # 2) 进程还在,做一次 HTTP 探活 + is_ok = self._is_iproxy_http_healthy(model.screenPort) + + if is_ok: + if self._iproxy_fail_count.get(udid): + msg = f"[iproxy] HTTP 探活恢复正常 | udid={udid}" + print(msg) + LogManager.info(msg, "iproxy") + self._iproxy_fail_count[udid] = 0 + continue + + # 3) HTTP 探活失败:记录一次失败 + fail = self._iproxy_fail_count.get(udid, 0) + 1 + self._iproxy_fail_count[udid] = fail + + msg = f"[iproxy] HTTP 探活失败 {fail} 次 | udid={udid}" + print(msg) + LogManager.warning(msg, "iproxy") + + FAIL_THRESHOLD = 3 + if fail >= FAIL_THRESHOLD: + msg = f"[iproxy] 连续 {fail} 次 HTTP 探活失败,准备重启 | udid={udid}" + print(msg) + LogManager.warning(msg, "iproxy") + + self._iproxy_fail_count[udid] = 0 + try: + self._stop_iproxy(udid) + self._start_iproxy(udid, model.screenPort) + except Exception as e: + msg = f"[iproxy] HTTP 探活重启失败 | udid={udid} | err={e}" + print(msg) + LogManager.warning(msg, "iproxy") + + # ========================== + # 移除设备 + # ========================== + def _remove_device(self, udid: str): + print(f"[Remove] 移除设备 {udid}") + + self._stop_iproxy(udid) + + with self._lock: + self._models.pop(udid, None) + self._last_seen.pop(udid, None) + self._iproxy_fail_count.pop(udid, None) + + self._manager_send() + + # ========================== + # 工具方法 + # ========================== + def _find_iproxy(self) -> str: + base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + name = "iproxy.exe" if os.name == "nt" else "iproxy" + return os.path.join(base, "resources", "iproxy", name) + + # ========================== + # 同步数据到 Flask + # ========================== + def _manager_send(self): + try: + self._send_snapshot_to_flask() + except Exception: + try: + self._manager.start() + except Exception: + pass + try: + self._send_snapshot_to_flask() + except Exception: + pass + + def _send_snapshot_to_flask(self): + with self._lock: + devices = [m.toDict() for m in self._models.values()] + + payload = json.dumps({"devices": devices}, ensure_ascii=False) + port = int(os.getenv("FLASK_COMM_PORT", "34566")) + + with socket.create_connection(("127.0.0.1", port), timeout=1.5) as s: + s.sendall(payload.encode() + b"\n") + + print(f"[SNAPSHOT] 已发送 {len(devices)} 台设备") \ No newline at end of file diff --git a/Module/FlaskService.py b/Module/FlaskService.py new file mode 100644 index 0000000..bda8386 --- /dev/null +++ b/Module/FlaskService.py @@ -0,0 +1,887 @@ +# -*- coding: utf-8 -*- +import json +import logging +import os +import socket +import threading +import time +from pathlib import Path +from queue import Queue +from typing import Any, Dict, List +import anyio +import wda +from quart import Quart, request, g +from quart_cors import cors +import Entity.Variables as ev +from Entity import Variables +from Entity.ResultData import ResultData +from Entity.Variables import addModelToAnchorList, wdaFunctionPort +from Utils.AiUtils import AiUtils +from Utils.ControlUtils import ControlUtils +from Utils.IOSAIStorage import IOSAIStorage +from Utils.JsonUtils import JsonUtils +from Utils.LogManager import LogManager +from Utils.ThreadManager import ThreadManager +from script.ScriptManager import ScriptManager + +for name in ('werkzeug', 'werkzeug.serving'): + log = logging.getLogger(name) + log.disabled = True + log.propagate = False + log.handlers.clear() + +app = Quart(__name__) # ⭐ 这里用 Quart,而不是 Flask +app = cors(app, allow_origin="*") # 允许所有来源跨域 + +app.config['JSON_AS_ASCII'] = False # Flask jsonify 不转义中文/emoji +app.config['JSONIFY_MIMETYPE'] = "application/json; charset=utf-8" + +# ============ 设备状态内存表 ============ +listData = [] +listLock = threading.Lock() + +# 历史遗留:不再消费队列,改为socket线程直接落地 +dataQueue = Queue() + +# ---- 黏性快照(避免瞬时空) ---- +_last_nonempty_snapshot = [] +_last_snapshot_ts = 0.0 +_STICKY_TTL_SEC = 10.0 # 在瞬时空时,回退到上一份非空快照10秒 +_empty_logged = False +_recovered_logged = False + +# ===== 设备集合变化跟踪 ===== +change_version = 0 +_device_ids_snapshot = set() +_last_device_count = 0 + +def _log_device_changes(action: str): + """记录设备集合增删变化""" + global _device_ids_snapshot, change_version + curr_ids = {d.get("deviceId") for d in listData if _is_online(d)} + added = curr_ids - _device_ids_snapshot + removed = _device_ids_snapshot - curr_ids + if added or removed: + change_version += 1 + try: + LogManager.info(f"[DEVICE][CHANGED][{action}] rev={change_version} count={len(curr_ids)} added={list(added)} removed={list(removed)}") + except Exception: + print(f"[DEVICE][CHANGED][{action}] rev={change_version} count={len(curr_ids)} added={list(added)} removed={list(removed)}") + _device_ids_snapshot = curr_ids + +def _normalize_type(v) -> int: + """把各种表示在线/离线的值,规范成 1/0""" + if isinstance(v, bool): + return 1 if v else 0 + if isinstance(v, (int, float)): + return 1 if int(v) == 1 else 0 + if isinstance(v, str): + s = v.strip().lower() + if s.isdigit(): + return 1 if int(s) == 1 else 0 + if s in ("true", "online", "on", "yes"): + return 1 + return 0 + return 1 if v else 0 + +def _is_online(d: Dict[str, Any]) -> bool: + return _normalize_type(d.get("type", 1)) == 1 + +def _apply_device_snapshot(devices: List[Dict[str, Any]]): + """接收 DeviceInfo 送来的全量设备列表,直接覆盖 listData""" + global listData + try: + normed = [] + for d in devices: + # 拷贝一份,避免引用共享 + d = dict(d) + d["type"] = _normalize_type(d.get("type", 1)) # 规范成 0/1 + normed.append(d) + + with listLock: + before = len(listData) + listData[:] = normed # 全量覆盖 + + _log_device_changes("SNAPSHOT") + try: + LogManager.info(f"[DEVICE][SNAPSHOT] size={len(normed)} (was={before})") + except Exception: + print(f"[DEVICE][SNAPSHOT] size={len(normed)} (was={before})") + except Exception as e: + LogManager.error(f"[DEVICE][SNAPSHOT][ERROR] {e}") + +def _apply_device_event(obj: Dict[str, Any]): + """把单条设备上线/下线事件落到 listData,并打印关键日志""" + try: + dev_id = obj.get("deviceId") + typ = _normalize_type(obj.get("type", 1)) + obj["type"] = typ # 写回规范后的值,避免后续被误判 + if dev_id is None: + LogManager.warning(f"[DEVICE][WARN] missing deviceId in obj={obj}") + return + with listLock: + before = len(listData) + # 删除同 udid 旧记录 + listData[:] = [d for d in listData if d.get("deviceId") != dev_id] + if typ == 1: + listData.append(obj) # 上线 + LogManager.info(f"[DEVICE][UPSERT] id={dev_id} type={typ} size={len(listData)} (replaced={before - (len(listData)-1)})") + _log_device_changes("UPSERT") + else: + LogManager.warning(f"[DEVICE][REMOVE] id={dev_id} type={typ} size={len(listData)} (removed_prev={before - len(listData)})") + _log_device_changes("REMOVE") + except Exception as e: + LogManager.error(f"[DEVICE][APPLY_EVT][ERROR] {e}") + +# ============ 设备事件 socket 监听 ============ +def _handle_conn(conn: socket.socket, addr): + """统一的连接处理函数(拆 JSON 行 → 正常化 type → 应用到 listData)""" + try: + with conn: + try: + conn.settimeout(3.0) # 避免永久阻塞 + except Exception: + pass + + buffer = "" + while True: + try: + data = conn.recv(1024) + if not data: # 对端关闭 + break + buffer += data.decode('utf-8', errors='ignore') + + # 按行切 JSON;发送端每条以 '\n' 结尾 + while True: + line, sep, buffer = buffer.partition('\n') + if not sep: + break + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError as e: + LogManager.warning(f"[SOCKET][WARN] 非法 JSON 丢弃: {line[:120]} err={e}") + continue + + # === 新增:如果是全量快照(包含 devices 字段) === + if "devices" in obj: + devs = obj.get("devices") or [] + LogManager.info(f"[SOCKET][RECV][SNAPSHOT] size={len(devs)} keys={list(obj.keys())}") + try: + _apply_device_snapshot(devs) + LogManager.info(f"[SOCKET][APPLY][SNAPSHOT] size={len(devs)}") + except Exception as e: + LogManager.error(f"[DEVICE][APPLY_SNAPSHOT][ERROR] {e}") + continue # 处理完这一条,继续下一条 JSON + + # === 否则按原来的单条设备事件处理(兼容旧逻辑) === + dev_id = obj.get("deviceId") + typ = _normalize_type(obj.get("type", 1)) + obj["type"] = typ # 规范 1/0 + LogManager.info(f"[SOCKET][RECV] deviceId={dev_id} type={typ} keys={list(obj.keys())}") + + try: + _apply_device_event(obj) # 保持你原来的增删逻辑 + LogManager.info(f"[SOCKET][APPLY] deviceId={dev_id} type={typ}") + except Exception as e: + # 单条业务异常不让线程死 + LogManager.error(f"[DEVICE][APPLY_EVT][ERROR] {e}") + + except (socket.timeout, ConnectionResetError, BrokenPipeError): + # 连接级异常:关闭该连接,回到 accept + break + except Exception as e: + LogManager.warning(f"[SOCKET][WARN] recv error: {e}") + break + except Exception as e: + LogManager.error(f"[SOCKET][ERROR] 连接处理异常: {e}") + +def start_socket_listener(): + """启动设备事件监听(仅走 FLASK_COMM_PORT;增强健壮性,不改业务)""" + # 统一使用 FLASK_COMM_PORT,默认 34566 + port = int(os.getenv('FLASK_COMM_PORT', 34566)) + LogManager.info(f"Received port from environment: {port}") + print(f"Received port from environment: {port}") + + if port <= 0: + LogManager.info("未获取到通信端口,跳过Socket监听") + print("未获取到通信端口,跳过Socket监听") + return + + backoff = 0.5 # 自愈退避,起于 0.5s,上限 8s + while True: + s = None + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + except Exception as e: + LogManager.warning(f"[SOCKET][WARN] setsockopt SO_REUSEADDR failed: {e}") + + try: + s.bind(('127.0.0.1', port)) + print(f"[INFO] Socket successfully bound to port {port}") + LogManager.info(f"[INFO] Socket successfully bound to port {port}") + except Exception as bind_error: + print(f"[ERROR]端口绑定失败: {bind_error}") + LogManager.info(f"[ERROR]端口绑定失败: {bind_error}") + # 绑定失败通常是端口未释放/竞争,退避后重试 + time.sleep(backoff) + backoff = min(backoff * 2, 8.0) + continue + + s.listen(256) + try: + s.settimeout(1.5) # accept 超时,便于检查自愈循环 + except Exception: + pass + + LogManager.info(f"[INFO] Socket listener started on port {port}, waiting for connections...") + print(f"[INFO] Socket listener started on port {port}, waiting for connections...") + # 监听成功 → 退避复位 + backoff = 0.5 + + while True: + try: + conn, addr = s.accept() + except socket.timeout: + # 定期“透气”,避免永久卡死;继续等待 + continue + except Exception as e: + # 发生 accept 级错误:重建 socket(进入外层 while 自愈) + LogManager.error(f"[ERROR] accept 失败: {e}") + break + + # 每个连接独立线程处理,保持你原来的做法 + threading.Thread(target=_handle_conn, args=(conn, addr), daemon=True).start() + + except Exception as e: + # 任意未兜住的异常,记录并进入退避自愈 + LogManager.error(f"[SOCKET][ERROR] 监听主循环异常: {e}") + time.sleep(backoff) + backoff = min(backoff * 2, 8.0) + finally: + try: + if s: + s.close() + except Exception: + pass + +# 独立线程启动 Socket 服务 + 看门狗 +def bootstrap_server_side_effects(): + # 仅在真正的 Flask 进程里启动副作用(监听、定时器、MQ 等) + listener_thread = threading.Thread(target=start_socket_listener, daemon=True) + listener_thread.start() + +# 获取app +def get_app(): + return app + +@app.before_request +def _log_request_start(): + g._start_ts = time.time() + LogManager.info( + text=f"[HTTP] START {request.method} {request.path}", + udid="flask" + ) + +@app.after_request +def _log_request_end(response): + cost = time.time() - getattr(g, "_start_ts", time.time()) + LogManager.info( + text=f"[HTTP] END {request.method} {request.path} {response.status_code} in {cost:.3f}s", + udid="flask" + ) + return response + + +# ============ API 路由 ============ +@app.route('/deviceList', methods=['GET']) +async def deviceList(): + global _last_device_count, change_version + global _last_nonempty_snapshot, _last_snapshot_ts, _STICKY_TTL_SEC + global _empty_logged, _recovered_logged + try: + with listLock: + # 宽容判定在线(字符串'1'/'true'/True 都算) + data = [d for d in listData if _is_online(d)] + now = time.time() + + # 记录最近一次非空快照 + if data: + _last_nonempty_snapshot = data.copy() + _last_snapshot_ts = now + if _recovered_logged: + LogManager.info(f"[API][deviceList][RECOVERED] count={len(data)} rev={change_version}") + _recovered_logged = False + _empty_logged = False + else: + # 瞬时空:在 TTL 内回退上一份非空快照 + if _last_nonempty_snapshot and (now - _last_snapshot_ts) <= _STICKY_TTL_SEC: + LogManager.warning(f"[API][deviceList][STICKY] serving last non-empty snapshot count={len(_last_nonempty_snapshot)} age={now - _last_snapshot_ts:.1f}s rev={change_version}") + return ResultData(data=_last_nonempty_snapshot).toJson() + if not _empty_logged: + LogManager.error(f"[API][deviceList][DROP_TO_EMPTY] last_count={_last_device_count} rev={change_version}") + _empty_logged = True + _recovered_logged = True + + _last_device_count = len(data) + LogManager.info(f"[API][deviceList] return_count={len(data)} rev={change_version}") + return ResultData(data=data).toJson() + except Exception as e: + LogManager.error(f"[API][deviceList] error={e}") + return ResultData(data=[]).toJson() + +@app.route('/passToken', methods=['POST']) +async def passToken(): + data = await request.get_json() + print(json.dumps(data)) + return ResultData(data="").toJson() + +# 获取设备应用列表 +@app.route('/deviceAppList', methods=['POST']) +async def deviceAppList(): + param = await request.get_json() + udid = param["udid"] + apps = ControlUtils.getDeviceAppList(udid) + return ResultData(data=apps).toJson() + +# 打开指定app +@app.route('/launchApp', methods=['POST']) +async def launchApp(): + body = await request.get_json() + udid = body.get("udid") + bundleId = body.get("bundleId") + t = wda.USBClient(udid, wdaFunctionPort) + t.session().app_start(bundleId) + return ResultData(data="").toJson() + +# 回到首页 +@app.route('/toHome', methods=['POST']) +async def toHome(): + body = await request.get_json() + udid = body.get("udid") + client = wda.USBClient(udid, wdaFunctionPort) + client.home() + return ResultData(data="").toJson() + +# 点击事件 +@app.route('/tapAction', methods=['POST']) +async def tapAction(): + body = await request.get_json() + udid = body.get("udid") + client = wda.USBClient(udid, wdaFunctionPort) + print("-----------------------") + print(client) + print("-----------------------") + session = client.session() + session.appium_settings({"snapshotMaxDepth": 0}) + x = body.get("x") + y = body.get("y") + session.tap(x, y) + return ResultData(data="").toJson() + +# 拖拽事件 +@app.route('/swipeAction', methods=['POST']) +async def swipeAction(): + body = await request.get_json() + udid = body.get("udid") + duration = body.get("duration") # 时长 + sx = body.get("sx") # 起始X点 + sy = body.get("sy") # 起始Y点 + ex = body.get("ex") # 结束X点 + ey = body.get("ey") # 结束Y点 + + client = wda.USBClient(udid, wdaFunctionPort) + session = client.session() + session.appium_settings({"snapshotMaxDepth": 0}) + + session.swipe(sx, sy, ex, ey, duration) + return ResultData(data="").toJson() + +# 长按事件 +@app.route('/longPressAction', methods=['POST']) +async def longPressAction(): + body = await request.get_json() + udid = body.get("udid") + x = body.get("x") + y = body.get("y") + client = wda.USBClient(udid, wdaFunctionPort) + session = client.session() + session.appium_settings({"snapshotMaxDepth": 5}) + session.tap_hold(x, y, 1.0) + return ResultData(data="").toJson() + +# 养号 +@app.route('/growAccount', methods=['POST']) +async def growAccount(): + body = await request.get_json() + udid = body.get("udid") + Variables.commentList = body.get("comment") + isComment = body.get("isComment") + + manager = ScriptManager() + event = threading.Event() + # 启动脚本 + thread = threading.Thread(target=manager.growAccount, args=(udid, isComment, event,)) + # 添加到线程管理 + code, msg = ThreadManager.add(udid, thread, event) + return ResultData(data="", code=code, message=msg).toJson() + +# 观看直播 +@app.route("/watchLiveForGrowth", methods=['POST']) +async def watchLiveForGrowth(): + body = await request.get_json() + udid = body.get("udid") + manager = ScriptManager() + event = threading.Event() + thread = threading.Thread(target=manager.watchLiveForGrowth, args=(udid, event)) + # 添加到线程管理 + ThreadManager.add(udid, thread, event) + return ResultData(data="").toJson() + +# 停止脚本 +@app.route("/stopScript", methods=['POST']) +async def stopScript(): + body = await request.get_json() + udid = body.get("udid") + LogManager.method_info(f"接口收到 /stopScript udid={udid}", method="task") + code, msg = ThreadManager.stop(udid) + return ResultData(code=code, data=[], message=msg).toJson() + +# 关注打招呼 +@app.route('/passAnchorData', methods=['POST']) +async def passAnchorData(): + try: + LogManager.method_info("关注打招呼", "关注打招呼") + data: Dict[str, Any] = await request.get_json() + # 设备列表 + idList = data.get("deviceList", []) + # 主播列表 + acList = data.get("anchorList", []) + Variables.commentList = data.get("comment") + isComment = data.get("isComment") + LogManager.info(f"[INFO] 获取数据: {idList} {acList}") + AiUtils.save_aclist_flat_append(acList) + # 是否需要回复 + needReply = data.get("needReply", False) + # 获取打招呼数据 + ev.prologueList = data.get("prologueList", []) + needTranslate = data.get("needTranslate", False) + + # 添加主播数据 + addModelToAnchorList(acList) + + failed_ids = [] + # 启动线程,执行脚本(单个设备异常不影响其它设备) + for udid in idList: + try: + manager = ScriptManager() + event = threading.Event() + thread = threading.Thread( + target=manager.safe_greetNewFollowers, + args=(udid, needReply, isComment, needTranslate, event,), + ) + ThreadManager.add(udid, thread, event) + except Exception as e: + failed_ids.append(udid) + LogManager.error(f"[passAnchorData] 设备 {udid} 启动脚本失败: {e}") + + # 如果所有设备都失败,可以考虑返回错误码 + if failed_ids and len(failed_ids) == len(idList): + return ResultData( + data="", + code=1001, + message=f"所有设备启动失败: {failed_ids}" + ).toJson() + + # 部分失败也算整体成功,只是记录一下 + if failed_ids: + LogManager.warning(f"[passAnchorData] 部分设备启动失败: {failed_ids}") + + return ResultData(data="").toJson() + except Exception as e: + LogManager.error(e) + return ResultData(data="", code=1001).toJson() + +@app.route('/followAndGreetUnion', methods=['POST']) +async def followAndGreetUnion(): + try: + LogManager.method_info("关注打招呼", "关注打招呼(联盟号)") + data: Dict[str, Any] = await request.get_json() + + # 设备列表 + idList = data.get("deviceList", []) + # 主播列表 + acList = data.get("anchorList", []) + LogManager.info(f"[INFO] 获取数据: {idList} {acList}") + + AiUtils.save_aclist_flat_append(acList) + + # 是否需要回复 + needReply = data.get("needReply", True) + needTranslate = data.get("needTranslate", False) + # 获取打招呼数据 + ev.prologueList = data.get("prologueList", []) + + # 添加主播数据 + addModelToAnchorList(acList) + + failed_ids = [] + + # 启动线程,执行脚本(单个设备异常不影响其它设备) + for udid in idList: + try: + manager = ScriptManager() + event = threading.Event() + thread = threading.Thread( + target=manager.safe_followAndGreetUnion, + args=(udid, needReply, needTranslate, event), + ) + ThreadManager.add(udid, thread, event) + except Exception as e: + failed_ids.append(udid) + LogManager.error(f"[followAndGreetUnion] 设备 {udid} 启动脚本失败: {e}") + + # 如果所有设备都失败,可以返回错误码 + if failed_ids and len(failed_ids) == len(idList): + return ResultData( + data="", + code=1001, + message=f"所有设备启动失败: {failed_ids}", + ).toJson() + + # 部分失败也算整体成功,只是记录一下 + if failed_ids: + LogManager.warning(f"[followAndGreetUnion] 部分设备启动失败: {failed_ids}") + + return ResultData(data="").toJson() + + except Exception as e: + LogManager.error(f"[followAndGreetUnion] 接口级异常: {e}") + return ResultData(data="", code=1001).toJson() + +# 获取私信数据 +@app.route("/getPrologueList", methods=['GET']) +async def getPrologueList(): + import Entity.Variables as Variables + return ResultData(data=Variables.prologueList).toJson() + +# 添加临时数据 +# 批量追加主播到 JSON 文件 +@app.route("/addTempAnchorData", methods=['POST']) +async def addTempAnchorData(): + """ + 请求体支持: + - 单个对象:{"anchorId": "xxx", "country": "CN"} + - 对象数组:[{"anchorId": "xxx", "country": "CN"}, {"anchorId": "yyy", "country": "US"}] + """ + data = await request.get_json() + if not data: + return ResultData(code=400, message="请求数据为空").toJson() + # 追加到 JSON 文件 + AiUtils.save_aclist_flat_append(data, "data/acList.json") + return ResultData(data="ok").toJson() + +# 获取当前屏幕上的聊天信息 +@app.route("/getChatTextInfo", methods=['POST']) +async def getChatTextInfo(): + data = await request.get_json() + udid = data.get("udid") + client = wda.USBClient(udid,wdaFunctionPort) + session = client.session() + xml = session.source() + try: + result = AiUtils.extract_messages_from_xml(xml) + + last_in = None + last_out = None + + for item in reversed(result): # 从后往前找 + if item.get('type') != 'msg': + continue + if last_in is None and item['dir'] == 'in': + last_in = item['text'] + if last_out is None and item['dir'] == 'out': + last_out = item['text'] + if last_in is not None and last_out is not None: + break + + print(f"检测出对方的最后一条数据:{last_in},{type(last_in)}") + print(f"检测出我的最后一条数据:{last_out},{type(last_out)}") + + return ResultData(data=result).toJson() + except Exception as e: + + LogManager.error(f"获取屏幕翻译出现错误:{e}", "获取屏幕翻译") + + data = [ + { + 'type': 'massage', + 'dir': 'in', + 'text': '当前页面无法获取聊天记录,请在tiktok聊天页面进行获取!!!' + }, + { + 'type': 'massage', + 'dir': 'in', + 'text': 'Unable to retrieve chat messages on the current screen. Please navigate to the TikTok chat page and try again!!!' + } + ] + return ResultData(data=data, message="解析失败").toJson() + +# 监控消息 +@app.route("/replyMessages", methods=['POST']) +async def monitorMessages(): + LogManager.method_info("开始监控消息,监控消息脚本启动", "监控消息") + body = await request.get_json() + udid = body.get("udid") + # Variables.commentList = body.get("comment") + + manager = ScriptManager() + event = threading.Event() + thread = threading.Thread(target=manager.replyMessages, args=(udid, event)) + LogManager.method_info("创建监控消息脚本线程成功", "监控消息") + # 添加到线程管理 + ThreadManager.add(udid, thread, event) + return ResultData(data="").toJson() + +# 上传日志 +@app.route("/setLoginInfo", methods=['POST']) +async def upLoadLogLogs(): + data = await request.get_json() # 解析 JSON + token = data.get("token") + userId = data.get("userId") + tenantId = data.get("tenantId") + ok = LogManager.upload_all_logs("http://47.79.98.113:8101/api/log/upload", token, userId, tenantId) + if ok: + return ResultData(data="日志上传成功").toJson() + else: + return ResultData(data="", message="日志上传失败").toJson() + +# 获取当前的主播列表数据 +@app.route("/anchorList", methods=['POST']) +async def queryAnchorList(): + # 项目根目录(当前文件在 infos 下,回退两层到根目录) + root_dir = Path(__file__).resolve().parent.parent + file_path = root_dir / "data" / "acList.json" + + data = [] + if file_path.exists(): + try: + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception as e: + LogManager.error(f"[anchorList] 读取失败: {e}") + data = [] + return ResultData(data=data).toJson() + +# 修改当前的主播列表数据 +@app.route("/updateAnchorList", methods=['POST']) +async def updateAnchorList(): + """ + invitationType: 1 普票 2 金票 + state: 1 通行(True) / 0 不通行(False) + """ + data = await request.get_json(force=True, silent=True) or {} + invitationType = data.get("invitationType") + state = bool(data.get("state")) # 转成布尔 + + # 要更新成的值 + new_status = 1 if state else 0 + + # 用工具类解析路径,避免 cwd 影响 + file_path = AiUtils._resolve_path("data/acList.json") + + # 加载 JSON + try: + doc = json.loads(file_path.read_text(encoding="utf-8-sig")) + except Exception as e: + LogManager.error(f"[updateAnchorList] 读取失败: {e}") + return ResultData(code=1001, message=f"暂无数据").toJson() + + # 定位 anchorList + if isinstance(doc, list): + acList = doc + wrapper = None + elif isinstance(doc, dict) and isinstance(doc.get("anchorList"), list): + acList = doc["anchorList"] + wrapper = doc + else: + return ResultData(code=500, message="文件格式不合法").toJson() + + # 遍历并更新 + updated = 0 + for item in acList: + if isinstance(item, dict) and item.get("invitationType") == invitationType: + item["state"] = new_status + updated += 1 + + # 写回(保持原始结构) + try: + file_path.parent.mkdir(parents=True, exist_ok=True) + to_write = wrapper if wrapper is not None else acList + file_path.write_text(json.dumps(to_write, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception as e: + LogManager.error(f"[updateAnchorList] 写入失败: {e}") + return ResultData(code=500, message=f"写入失败: {e}").toJson() + + if updated: + return ResultData(data=updated, message=f"已更新 {updated} 条记录").toJson() + else: + return ResultData(data=0, message="未找到符合条件的记录").toJson() + +# 删除主播 +@app.route("/deleteAnchorWithIds", methods=['POST']) +async def deleteAnchorWithIds(): + ls: list[dict] = await request.get_json() # [{"anchorId": "xxx"}, ...] + ids = [d.get("anchorId") for d in ls if d.get("anchorId")] + deleted = AiUtils.delete_anchors_by_ids(ids) + return ResultData(data={"deleted": deleted}).toJson() + +# 配置ai人设 +@app.route("/aiConfig", methods=['POST']) +async def aiConfig(): + data = await request.get_json() + agentName = data.get("agentName") + guildName = data.get("guildName") + contactTool = data.get("contactTool") + contact = data.get("contact") + + age = data.get("age") + sex = data.get("sex") + height = data.get("height") + weight = data.get("weight") + body_features = data.get("body_features") + nationality = data.get("nationality") + personality = data.get("personality") + strengths = data.get("strengths") + + dict = { + "agentName": agentName, + "guildName": guildName, + "contactTool": contactTool, + "contact": contact, + "age": age, + "sex": sex, + "height": height, + "weight": weight, + "body_features": body_features, + "nationality": nationality, + "personality": personality, + "strengths": strengths, + "api-key": "app-sdRfZy2by9Kq7uJg7JdOSVr8" + } + + # JsonUtils.write_json("aiConfig", dict) + IOSAIStorage.overwrite(dict, "aiConfig.json") + return ResultData(data="").toJson() + +# 查询主播聊天发送的最后一条信息 +@app.route("/select_last_message", methods=['GET']) +async def select_last_message(): + data = JsonUtils.query_all_json_items() + return ResultData(data=data).toJson() + +# 修改消息(已读改成未读) +@app.route("/update_last_message", methods=['POST']) +async def update_last_message(): + data = await request.get_json() # 解析 JSON + sender = data.get("sender") + udid = data.get("device") + text = data.get("text") + + updated_count = JsonUtils.update_json_items( + match={"sender": sender, "text": text}, # 匹配条件 + patch={"status": 1}, # 修改内容 + filename="log/last_message.json", # 要修改的文件 + multi=True # 只改第一条匹配的 + ) + if updated_count > 0: + return ResultData(data=updated_count, message="修改成功").toJson() + return ResultData(data=updated_count, message="修改失败").toJson() + +# 删除已读消息 +@app.route("/delete_last_message", methods=['POST']) +async def delete_last_message(): + data = await request.get_json() # 解析 JSON + sender = data.get("sender") + udid = data.get("device") + text = data.get("text") + + updated_count = JsonUtils.delete_json_items( + match={"sender": sender, "text": text}, # 匹配条件 + filename="log/last_message.json", # 要修改的文件 + multi=True # 只改第一条匹配的 + ) + if updated_count > 0: + return ResultData(data=updated_count, message="修改成功").toJson() + + return ResultData(data=updated_count, message="修改失败").toJson() + +# 停止所有任务 +@app.route("/stopAllTask", methods=['POST']) +async def stopAllTask(): + idList = await request.get_json() + code, msg, data = ThreadManager.batch_stop(idList) + return ResultData(code, data, msg).toJson() + +# 切换账号 +@app.route('/changeAccount', methods=['POST']) +async def changeAccount(): + body = await request.get_json() + udid = body.get("udid") + if not udid: + return ResultData(data="", code=400, message="缺少 udid").toJson() + + manager = ScriptManager() + threading.Event() + + # 启动脚本 + code, msg = manager.changeAccount(udid) + # thread = threading.Thread(target=, args=(udid,)) + # # 添加到线程管理 + # thread.start() + return ResultData(data="", code=code, message=msg).toJson() + +# 查看设备网络状态 +@app.route('/getDeviceNetStatus', methods=['POST']) +async def getDeviceNetStatus(): + body = await request.get_json() + udid = body.get("udid") + # 同步且超级慢的逻辑 → 丢到线程池,不阻塞事件循环 + def _work(): + client = wda.USBClient(udid, wdaFunctionPort) + r = client.getNetWorkStatus() + return r.get("value") + value = await anyio.to_thread.run_sync(_work) + + return ResultData(data=value, code=200).toJson() + +# 获取ai配置 +@app.route("/getAiConfig", methods=['GET']) +def getAiConfig(): + data = IOSAIStorage.load("aiConfig.json") + return ResultData(data=data).toJson() + +# 重新开启tiktok +@app.route("/restartTikTok", methods=['POST']) +async def restartTikTok(): + json = await request.get_json() + udid = json.get("udid") + client = wda.USBClient(udid, wdaFunctionPort) + session = client.session() + ControlUtils.closeTikTok(session, udid) + time.sleep(1) + ControlUtils.openTikTok(session, udid) + return ResultData(data="").toJson() + +# 健康检查 +@app.get("/health") +async def health(): + return {"status": "ok"} + +if __name__ == '__main__': + # 只有“明确是 Flask 进程”才跑副作用(通过 APP_ROLE 控制) + app.run("0.0.0.0", port=5000, debug=False, use_reloader=False, threaded=True) diff --git a/Module/FlaskSubprocessManager.py b/Module/FlaskSubprocessManager.py new file mode 100644 index 0000000..df701d9 --- /dev/null +++ b/Module/FlaskSubprocessManager.py @@ -0,0 +1,270 @@ +# -*- coding: utf-8 -*- +import atexit +import json +import os +import socket +import subprocess +import sys +import threading +import time +from pathlib import Path +from typing import Optional, Union, Dict, List + +from Utils.LogManager import LogManager + + +class FlaskSubprocessManager: + """ + 超稳定版 Flask 子进程守护 + - 单线程 watchdog(唯一监控点) + - 强制端口检测 + - 端口不通 / 子进程退出 → 100% 重启 + - 完整支持 exe + Python 模式 + - 自动恢复设备列表快照 + """ + + _instance = None + _lock = threading.RLock() + + def __new__(cls): + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialize() + return cls._instance + + # ========================= 初始化 ========================= + def _initialize(self): + self.process: Optional[subprocess.Popen] = None + self.comm_port = 34566 + self._watchdog_running = False + self._stop_event = threading.Event() + + self._restart_cooldown = 5 # 每次重启最少间隔 + self._restart_fail_threshold = 3 # 端口检查连续失败几次才重启 + self._restart_fail_count = 0 + + self._restart_window = 600 # 10 分钟 + self._restart_limit = 5 # 最多次数 + self._restart_record: List[float] = [] + + if os.name == "nt": + si = subprocess.STARTUPINFO() + si.dwFlags |= subprocess.STARTF_USESHOWWINDOW + si.wShowWindow = 0 + self._si = si + else: + self._si = None + + atexit.register(self.stop) + self._kill_orphans() + + LogManager.info("FlaskSubprocessManager 初始化完成", udid="flask") + + # ========================= 工具 ========================= + def _log(self, level, msg): + print(msg) + if level == "info": + LogManager.info(msg, udid="flask") + elif level == "warn": + LogManager.warning(msg, udid="flask") + else: + LogManager.error(msg, udid="flask") + + # 杀死残留 python.exe 占用端口 + def _kill_orphans(self): + try: + if os.name == "nt": + out = subprocess.check_output(["netstat", "-ano"], text=True) + for line in out.splitlines(): + if f"127.0.0.1:{self.comm_port}" in line and "LISTENING" in line: + pid = int(line.strip().split()[-1]) + if pid != os.getpid(): + subprocess.run( + ["taskkill", "/F", "/PID", str(pid)], + capture_output=True + ) + self._log("warn", f"[FlaskMgr] 杀死残留 Flask 实例 PID={pid}") + except Exception: + pass + + def _port_alive(self): + """检测 Flask 与 Quart 的两个端口是否活着""" + def _check(p): + try: + with socket.create_connection(("127.0.0.1", p), timeout=0.4): + return True + except Exception: + return False + + return _check(self.comm_port) or _check(self.comm_port + 1) + + # ========================= 启动 ========================= + # ========================= 启动 ========================= + def start(self): + with self._lock: + # 已经有一个在跑了就别重复起 + if self.process and self.process.poll() is None: + self._log("warn", "[FlaskMgr] Flask 已在运行,跳过") + return + + # 设定环境变量,给子进程用 + env = os.environ.copy() + env["FLASK_COMM_PORT"] = str(self.comm_port) + + # ✅ 正确判断是否是 Nuitka/打包后的 exe + # - 被 Nuitka 打包:sys.frozen 会存在/为 True + # - 直接用 python 跑 .py:sys.frozen 不存在 + is_frozen = bool(getattr(sys, "frozen", False)) + + if is_frozen: + # 打包后的 exe 模式:直接调用自己 + exe = Path(sys.executable).resolve() + cmd = [str(exe), "--role=flask"] + cwd = str(exe.parent) + else: + # 开发模式:用 python 去跑 Module/Main.py --role=flask + project_root = Path(__file__).resolve().parents[1] + main_py = project_root / "Module" / "Main.py" + cmd = [sys.executable, "-u", str(main_py), "--role=flask"] + cwd = str(project_root) + + self._log("info", f"[FlaskMgr] 启动 Flask: {cmd}") + + self.process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + cwd=cwd, + bufsize=1, + startupinfo=self._si, + start_new_session=True, + ) + + # 异步吃子进程 stdout,顺便打日志 + threading.Thread(target=self._read_stdout, daemon=True).start() + + # 看门狗只需要起一次 + if not self._watchdog_running: + threading.Thread(target=self._watchdog_loop, daemon=True).start() + self._watchdog_running = True + + self._log("info", f"[FlaskMgr] Flask 子进程已启动 PID={self.process.pid}") + + def _read_stdout(self): + if not self.process or not self.process.stdout: + return + for line in iter(self.process.stdout.readline, ""): + if line: + self._log("info", f"[Flask] {line.rstrip()}") + + # ========================= 停止 ========================= + def stop(self): + with self._lock: + if not self.process: + return + + try: + self.process.terminate() + except Exception: + pass + + try: + self.process.wait(timeout=3) + except Exception: + pass + + if self.process.poll() is None: + try: + self.process.kill() + except Exception: + pass + + self._log("warn", "[FlaskMgr] 已停止 Flask 子进程") + self.process = None + + # ========================= 看门狗 ========================= + def _watchdog_loop(self): + self._log("info", "[FlaskWD] 看门狗已启动") + + while not self._stop_event.is_set(): + time.sleep(1.2) + + # 1) 子进程退出 + if not self.process or self.process.poll() is not None: + self._log("error", "[FlaskWD] Flask 子进程退出,准备重启") + self._restart() + continue + + # 2) 端口不通 + if not self._port_alive(): + self._restart_fail_count += 1 + self._log("warn", f"[FlaskWD] 端口检测失败 {self._restart_fail_count}/" + f"{self._restart_fail_threshold}") + + if self._restart_fail_count >= self._restart_fail_threshold: + self._restart() + continue + + # 3) 端口正常 + self._restart_fail_count = 0 + + # ========================= 重启核心逻辑 ========================= + def _restart(self): + now = time.time() + + # 10 分钟限频 + self._restart_record = [t for t in self._restart_record if now - t < self._restart_window] + if len(self._restart_record) >= self._restart_limit: + self._log("error", "[FlaskWD] 10 分钟内重启次数太多,暂停监控") + return + + # 冷却 + if self._restart_record and now - self._restart_record[-1] < self._restart_cooldown: + self._log("warn", "[FlaskWD] 冷却中,暂不重启") + return + + self._log("warn", "[FlaskWD] >>> 重启 Flask 子进程 <<<") + + # 执行重启 + try: + self.stop() + time.sleep(1) + self.start() + self._restart_record.append(now) + self._restart_fail_count = 0 + except Exception as e: + self._log("error", f"[FlaskWD] 重启失败: {e}") + + # 重启后推送设备快照 + self._push_snapshot() + + # ========================= 推送设备快照 ========================= + def _push_snapshot(self): + """Flask 重启后重新同步 DeviceInfo 内容""" + try: + from Module.DeviceInfo import DeviceInfo + info = DeviceInfo() + with info._lock: + for m in info._models.values(): + self.send(m.toDict()) + except Exception: + pass + + # ========================= 发送数据 ========================= + def send(self, data: Union[str, Dict]): + if isinstance(data, dict): + data = json.dumps(data, ensure_ascii=False) + + try: + with socket.create_connection(("127.0.0.1", self.comm_port), timeout=2) as s: + s.sendall((data + "\n").encode()) + return True + except Exception: + return False + + @classmethod + def get_instance(cls): + return cls() \ No newline at end of file diff --git a/Module/IOSActivator.py b/Module/IOSActivator.py new file mode 100644 index 0000000..50a8a2b --- /dev/null +++ b/Module/IOSActivator.py @@ -0,0 +1,355 @@ +import os +import sys +import time +import threading +import subprocess +from typing import Optional, Callable + +from Entity.Variables import WdaAppBundleId + + +class IOSActivator: + """ + 给 iOS17+ 用的 go-ios 激活器(单例): + - 维护一条全局 tunnel 进程 + - 流程:tunnel start -> pair(可多次重试) -> image auto(非致命) -> runwda(多次重试+日志判定成功) + - WDA 启动成功后触发回调 on_wda_ready(udid) + """ + + # ===== 单例 & 全局 tunnel ===== + _instance = None + _instance_lock = threading.Lock() + + _tunnel_proc: Optional[subprocess.Popen] = None + _tunnel_lock = threading.Lock() + + def __new__(cls, *args, **kwargs): + with cls._instance_lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__( + self, + ios_path: Optional[str] = None, + pair_timeout: int = 60, # 配对最多等多久 + pair_retry_interval: int = 3, # 配对重试间隔 + runwda_max_retry: int = 10, # runwda 最大重试次数 + runwda_retry_interval: int = 3, # runwda 重试间隔 + runwda_wait_timeout: int = 25 # 单次 runwda 等待“成功日志”的超时时间 + ): + if getattr(self, "_inited", False): + return + + # 运行路径处理(源码 / Nuitka EXE) + if "__compiled__" in globals(): + base_dir = os.path.dirname(sys.executable) + else: + cur_file = os.path.abspath(__file__) + base_dir = os.path.dirname(os.path.dirname(cur_file)) + + resource_dir = os.path.join(base_dir, "resources") + if not ios_path: + ios_path = os.path.join(resource_dir, "ios.exe") + + self.ios_path = ios_path + self.pair_timeout = pair_timeout + self.pair_retry_interval = pair_retry_interval + self.runwda_max_retry = runwda_max_retry + self.runwda_retry_interval = runwda_retry_interval + self.runwda_wait_timeout = runwda_wait_timeout + + self._lock = threading.Lock() + + # ========= 关键:这里改成“真正隐藏窗口”的安全版 ========= + self._creationflags = 0 + self._startupinfo = None + if os.name == "nt": + try: + # 只用 CREATE_NO_WINDOW,不搞 DETACHED_PROCESS / NEW_PROCESS_GROUP 之类的骚操作 + self._creationflags = subprocess.CREATE_NO_WINDOW # type: ignore[attr-defined] + except Exception: + self._creationflags = 0 + + si = subprocess.STARTUPINFO() + try: + si.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore[attr-defined] + except Exception: + # 某些极端环境下可能没有 STARTF_USESHOWWINDOW,忽略即可 + pass + si.wShowWindow = 0 # SW_HIDE + self._startupinfo = si + # ========= 关键部分结束 ========= + + self._inited = True + + # ===== 通用同步命令执行 ===== + def _run( + self, + args, + desc: str = "", + timeout: Optional[int] = None, + check: bool = True, + ): + cmd = [self.ios_path] + list(args) + cmd_str = " ".join(cmd) + if desc: + print(f"[ios] 执行命令({desc}): {cmd_str}") + else: + print(f"[ios] 执行命令: {cmd_str}") + + try: + proc = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=timeout, + creationflags=self._creationflags, + startupinfo=self._startupinfo, + ) + except subprocess.TimeoutExpired: + if check: + raise + return -1, "", "timeout" + + out = proc.stdout or "" + err = proc.stderr or "" + if check and proc.returncode != 0: + print(f"[ios] 命令失败({desc}), rc={proc.returncode}") + raise RuntimeError(f"[ios] 命令失败({desc}), returncode={proc.returncode}") + + return proc.returncode, out, err + + # ===== tunnel 相关 ===== + def _drain_process_output(self, proc: subprocess.Popen, name: str): + """吃掉后台进程输出,防止缓冲区阻塞""" + try: + if proc.stdout: + for line in proc.stdout: + line = line.rstrip() + if line: + print(f"[ios][{name}] {line}") + except Exception as e: + print(f"[ios][{name}] 读取 stdout 异常: {e}") + + try: + if proc.stderr: + for line in proc.stderr: + line = line.rstrip() + if line: + print(f"[ios][{name}][stderr] {line}") + except Exception as e: + print(f"[ios][{name}] 读取 stderr 异常: {e}") + + def _spawn_tunnel(self): + """启动 / 复用全局 tunnel(不隐藏窗口)""" + with IOSActivator._tunnel_lock: + # 已有并且还在跑就复用 + if IOSActivator._tunnel_proc is not None and IOSActivator._tunnel_proc.poll() is None: + print("[ios] tunnel 已经在运行,跳过重新启动") + return + + cmd = [self.ios_path, "tunnel", "start"] + print("[ios] 启动 go-ios tunnel:", " ".join(cmd)) + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + creationflags=self._creationflags, # 0:不隐藏 + startupinfo=self._startupinfo, # None:不隐藏 + ) + except Exception as e: + print("[ios] 启动 tunnel 失败(忽略):", e) + return + + IOSActivator._tunnel_proc = proc + print("[ios] tunnel 启动成功, PID=", proc.pid) + + # 后台吃日志 + threading.Thread( + target=self._drain_process_output, + args=(proc, "tunnel"), + daemon=True, + ).start() + + # ===== pair & image ===== + def _pair_until_success(self, udid: str): + deadline = time.time() + self.pair_timeout + attempt = 0 + + while True: + attempt += 1 + print(f"[ios] 开始配对设备({udid}),第 {attempt} 次尝试") + + rc, out, err = self._run( + ["--udid", udid, "pair"], + desc=f"pair({udid})", + timeout=20, + check=False, + ) + + text = (out or "") + "\n" + (err or "") + # 打印一份完整输出,方便调试 + if text.strip(): + print("[ios][pair] output:\n", text.strip()) + + if "Successfully paired" in text: + print(f"[ios] 设备 {udid} 配对成功") + return + + if time.time() >= deadline: + raise RuntimeError(f"[ios] 设备 {udid} 在超时时间内配对失败(rc={rc})") + + time.sleep(self.pair_retry_interval) + + def _mount_dev_image(self, udid: str): + print(f"[ios] 开始为设备 {udid} 挂载开发者镜像 (image auto)") + rc, out, err = self._run( + ["--udid", udid, "image", "auto"], + desc=f"image auto({udid})", + timeout=300, + check=False, + ) + + text = (out or "") + "\n" + (err or "") + text_lower = text.lower() + success_keywords = [ + "success mounting image", + "there is already a developer image mounted", + ] + if any(k in text_lower for k in success_keywords): + print(f"[ios] 设备 {udid} 开发者镜像挂载完成") + if text.strip(): + print("[ios][image auto] output:\n", text.strip()) + return + + print(f"[ios] 设备 {udid} 挂载开发者镜像可能失败(rc={rc}),输出:\n{text.strip()}") + + # ===== runwda(关键逻辑) ===== + def _run_wda_once_async(self, udid: str, on_wda_ready: Optional[Callable[[str], None]]) -> bool: + """ + 单次 runwda: + - 异步启动 ios.exe + - 实时读 stdout/stderr + - 捕获关键日志(got capabilities / authorized true)视为成功 + - 超时/进程退出且未成功 -> 失败 + """ + cmd = [ + self.ios_path, + f"--udid={udid}", + "runwda", + f"--bundleid={WdaAppBundleId}", + f"--testrunnerbundleid={WdaAppBundleId}", + "--xctestconfig=yolo.xctest", + ] + print("[ios] 异步启动 runwda:", " ".join(cmd)) + + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + creationflags=self._creationflags, # 0:不隐藏 + startupinfo=self._startupinfo, + ) + except Exception as e: + print(f"[ios] 启动 runwda 进程失败: {e}") + return False + + success_evt = threading.Event() + + def _reader(pipe, tag: str): + try: + for raw in pipe: + line = (raw or "").rstrip() + if not line: + continue + print(f"[WDA-LOG] {line}") + lower = line.lower() + # 你实测的“成功特征” + if "got capabilities" in lower or '"authorized":true' in lower: + success_evt.set() + print(f"[ios] 捕获到 WDA 启动成功日志({tag}),udid={udid}") + break + except Exception as e: + print(f"[ios] 读取 {tag} 日志异常: {e}") + + # 日志线程 + if proc.stdout: + threading.Thread(target=_reader, args=(proc.stdout, "stdout"), daemon=True).start() + if proc.stderr: + threading.Thread(target=_reader, args=(proc.stderr, "stderr"), daemon=True).start() + + # 等待成功 / 退出 / 超时 + start = time.time() + while True: + if success_evt.is_set(): + print(f"[ios] WDA 日志确认已启动,udid={udid}") + if on_wda_ready: + try: + on_wda_ready(udid) + except Exception as e: + print(f"[WDA] 回调执行异常: {e}") + # 不主动杀进程,让 WDA 挂在那儿 + return True + + rc = proc.poll() + if rc is not None: + print(f"[ios] runwda 进程退出 rc={rc},未检测到成功日志,udid={udid}") + return False + + if time.time() - start > self.runwda_wait_timeout: + print(f"[ios] runwda 等待超时({self.runwda_wait_timeout}s),未确认成功,udid={udid}") + try: + proc.terminate() + except Exception: + pass + return False + + time.sleep(0.2) + + def _run_wda_with_retry(self, udid: str, on_wda_ready: Optional[Callable[[str], None]]) -> bool: + for attempt in range(1, self.runwda_max_retry + 1): + print(f"[ios] runwda 尝试 {attempt}/{self.runwda_max_retry},udid={udid}") + ok = self._run_wda_once_async(udid, on_wda_ready) + if ok: + print(f"[ios] runwda 第 {attempt} 次尝试成功,udid={udid}") + return True + + print(f"[ios] runwda 第 {attempt} 次尝试失败,udid={udid}") + if attempt < self.runwda_max_retry: + time.sleep(self.runwda_retry_interval) + + print(f"[ios] runwda 多次失败,放弃,udid={udid}") + return False + + # ===== 对外主流程 ===== + def activate_ios17(self, udid: str, on_wda_ready: Optional[Callable[[str], None]] = None) -> None: + print(f"[WDA] iOS17+ 激活开始,udid={udid}, 回调={on_wda_ready}") + + # 1. 先确保 tunnel 在跑 + self._spawn_tunnel() + + # 2. 配对 + try: + self._pair_until_success(udid) + except Exception as e: + print(f"[WDA] pair 失败,终止激活流程 udid={udid}, err={e}") + return + + # 3. 挂镜像(非致命) + try: + self._mount_dev_image(udid) + except Exception as e: + print(f"[WDA] 挂载开发者镜像异常(忽略) udid={udid}, err={e}") + + # 4. runwda + 回调 + ok = self._run_wda_with_retry(udid, on_wda_ready) + if not ok: + print(f"[WDA] runwda 多次失败,可能需要手动检查设备,udid={udid}") + + print(f"[WDA] iOS17+ 激活流程结束(不代表一定成功),udid={udid}") \ No newline at end of file diff --git a/Module/Main.py b/Module/Main.py new file mode 100644 index 0000000..cfd32f4 --- /dev/null +++ b/Module/Main.py @@ -0,0 +1,176 @@ +import asyncio +import ctypes +# ===== Main.py 顶部放置(所有 import 之前)===== +import os +import sys +from pathlib import Path +from hypercorn.asyncio import serve +from hypercorn.config import Config + +from Module.DeviceInfo import DeviceInfo +from Module.FlaskSubprocessManager import FlaskSubprocessManager +from Utils.AiUtils import AiUtils +from Utils.DevDiskImageDeployer import DevDiskImageDeployer +from Utils.LogManager import LogManager + +# 确定 exe 或 py 文件所在目录 +BASE = Path(getattr(sys, 'frozen', False) and sys.executable or __file__).resolve().parent +LOG_DIR = BASE / "log" +LOG_DIR.mkdir(exist_ok=True) # 确保 log 目录存在 + +print(f"日志目录: {LOG_DIR}") + +def _run_flask_role(): + from Module.FlaskService import get_app, bootstrap_server_side_effects + print("Flask Pid:", os.getpid()) + port = int(os.getenv("FLASK_COMM_PORT", "34566")) # 固定端口的兜底仍是 34567 + app = get_app() + flaskPort = port + 1 + AiUtils.flask_port_free(flaskPort) + bootstrap_server_side_effects() + + # ==== 关键:统一获取 resources 目录 ==== + if "__compiled__" in globals(): + # 被 Nuitka 编译后的 exe 运行时 + base_dir = os.path.dirname(sys.executable) # exe 所在目录 + else: + # 开发环境,直接跑 .py + cur_file = os.path.abspath(__file__) # Module/Main.py 所在目录 + base_dir = os.path.dirname(os.path.dirname(cur_file)) # 回到项目根 iOSAi + + resource_dir = os.path.join(base_dir, "resources") + + # Hypercorn 配置 + config = Config() + config.bind = [f"0.0.0.0:{flaskPort}"] + config.certfile = os.path.join(resource_dir, "server.crt") + config.keyfile = os.path.join(resource_dir, "server.key") + config.alpn_protocols = ["h2", "http/1.1"] + config.workers = 6 # 你机器 4GB → 推荐 3~4 个 worker + + # 直接跑 Quart(ASGI 原生,不再用 WsgiToAsgi) + asyncio.run(serve(app, config)) + +if "--role=flask" in sys.argv: + _run_flask_role() + sys.exit(0) + +def _ensure_wintun_installed(): + """ + 确保 wintun.dll 已经在系统目录里: + - 优先从当前目录的 resources 中找 wintun.dll + - 如果 System32 中没有,就复制过去(需要管理员权限) + """ + try: + # ==== 关键:统一获取 resources 目录 ==== + if "__compiled__" in globals(): + # Nuitka 编译后的 exe + base_dir = os.path.dirname(sys.executable) # exe 所在目录 + else: + # 开发环境运行 .py + cur_file = os.path.abspath(__file__) # Module/Main.py 所在目录 + base_dir = os.path.dirname(os.path.dirname(cur_file)) # 回到 iOSAi 根目录 + + resource_dir = os.path.join(base_dir, "resources") + src = os.path.join(resource_dir, "wintun.dll") + + # 1. 检查源文件是否存在 + if not os.path.exists(src): + print(f"[wintun] 未找到资源文件: {src}") + return + + # 2. 系统 System32 目录 + windir = os.environ.get("WINDIR", r"C:\Windows") + system32 = Path(windir) / "System32" + dst = system32 / "wintun.dll" + + # 3. System32 中已经存在则无需复制 + if dst.exists(): + print(f"[wintun] System32 中已存在: {dst}") + return + + # 4. 执行复制 + import shutil + print(f"[wintun] 复制 {src} -> {dst}") + shutil.copy2(src, dst) + print("[wintun] 复制完成") + + except PermissionError as e: + print(f"[wintun] 权限不足,无法写入 System32:{e}") + except Exception as e: + print(f"[wintun] 安装 wintun.dll 时异常: {e}") + +# 启动锁 +def main(arg): + if len(arg) != 2 or arg[1] != "iosai": + sys.exit(0) + +# 判断是否为管理员身份原型 +def isAdministrator(): + """ + 检测当前进程是否具有管理员权限。 + - Windows 下调用 Shell32.IsUserAnAdmin() + - 如果不是管理员,直接退出程序 + """ + try: + is_admin = ctypes.windll.shell32.IsUserAnAdmin() + except Exception: + # 非 Windows 或无法判断的情况,一律按“非管理员”处理 + is_admin = False + + if not is_admin: + print("[ERROR] 需要以管理员身份运行本程序!") + sys.exit(0) + + return True + +# 项目入口 +if __name__ == "__main__": + # 检测是否有管理员身份权限 + isAdministrator() + + # 检测程序合法性 + main(sys.argv) + + # 清空日志 + LogManager.clearLogs() + + # 添加iOS开发包到电脑上 + deployer = DevDiskImageDeployer(verbose=True) + deployer.deploy_all() + + # 复制wintun.dll到system32目录下 + _ensure_wintun_installed() + + # 启动 Flask 子进程 + manager = FlaskSubprocessManager.get_instance() + manager.start() + + # 设备监听(即使失败/很快返回,也不会导致主进程退出) + try: + info = DeviceInfo() + info.listen() + except Exception as e: + print("[WARN] Device listener not running:", e) + + # === 保活:阻塞主线程,直到收到 Ctrl+C/关闭 === + import threading, time, signal + stop = threading.Event() + + def _handle(_sig, _frm): + stop.set() + + # Windows 上 SIGINT/SIGTERM 都可以拦到 + try: + signal.signal(signal.SIGINT, _handle) + signal.signal(signal.SIGTERM, _handle) + except Exception: + pass # 某些环境可能不支持,忽略 + + try: + while not stop.is_set(): + time.sleep(1) + finally: + # 进程退出前记得把子进程关掉 + manager.stop() + diff --git a/SupportFiles/14.0.zip b/SupportFiles/14.0.zip new file mode 100644 index 0000000..bd7a539 Binary files /dev/null and b/SupportFiles/14.0.zip differ diff --git a/SupportFiles/14.1.zip b/SupportFiles/14.1.zip new file mode 100644 index 0000000..ceb8328 Binary files /dev/null and b/SupportFiles/14.1.zip differ diff --git a/SupportFiles/14.2.zip b/SupportFiles/14.2.zip new file mode 100644 index 0000000..6190de1 Binary files /dev/null and b/SupportFiles/14.2.zip differ diff --git a/SupportFiles/14.3.zip b/SupportFiles/14.3.zip new file mode 100644 index 0000000..1203d70 Binary files /dev/null and b/SupportFiles/14.3.zip differ diff --git a/SupportFiles/14.4.zip b/SupportFiles/14.4.zip new file mode 100644 index 0000000..a1025db Binary files /dev/null and b/SupportFiles/14.4.zip differ diff --git a/SupportFiles/14.5.zip b/SupportFiles/14.5.zip new file mode 100644 index 0000000..d91b781 Binary files /dev/null and b/SupportFiles/14.5.zip differ diff --git a/SupportFiles/14.6.zip b/SupportFiles/14.6.zip new file mode 100644 index 0000000..9ed5e41 Binary files /dev/null and b/SupportFiles/14.6.zip differ diff --git a/SupportFiles/14.7.zip b/SupportFiles/14.7.zip new file mode 100644 index 0000000..3a8f01a Binary files /dev/null and b/SupportFiles/14.7.zip differ diff --git a/SupportFiles/14.8.zip b/SupportFiles/14.8.zip new file mode 100644 index 0000000..ea9c3a6 Binary files /dev/null and b/SupportFiles/14.8.zip differ diff --git a/SupportFiles/15.0.zip b/SupportFiles/15.0.zip new file mode 100644 index 0000000..a91df82 Binary files /dev/null and b/SupportFiles/15.0.zip differ diff --git a/SupportFiles/15.1.zip b/SupportFiles/15.1.zip new file mode 100644 index 0000000..bd07873 Binary files /dev/null and b/SupportFiles/15.1.zip differ diff --git a/SupportFiles/15.2.zip b/SupportFiles/15.2.zip new file mode 100644 index 0000000..134c25b Binary files /dev/null and b/SupportFiles/15.2.zip differ diff --git a/SupportFiles/15.3.zip b/SupportFiles/15.3.zip new file mode 100644 index 0000000..1a75175 Binary files /dev/null and b/SupportFiles/15.3.zip differ diff --git a/SupportFiles/15.4.zip b/SupportFiles/15.4.zip new file mode 100644 index 0000000..b62fa2d Binary files /dev/null and b/SupportFiles/15.4.zip differ diff --git a/SupportFiles/15.5.zip b/SupportFiles/15.5.zip new file mode 100644 index 0000000..d84ff97 Binary files /dev/null and b/SupportFiles/15.5.zip differ diff --git a/SupportFiles/15.6.zip b/SupportFiles/15.6.zip new file mode 100644 index 0000000..bc450f3 Binary files /dev/null and b/SupportFiles/15.6.zip differ diff --git a/SupportFiles/15.7.zip b/SupportFiles/15.7.zip new file mode 100644 index 0000000..13ab765 Binary files /dev/null and b/SupportFiles/15.7.zip differ diff --git a/SupportFiles/15.8.zip b/SupportFiles/15.8.zip new file mode 100644 index 0000000..f413db6 Binary files /dev/null and b/SupportFiles/15.8.zip differ diff --git a/SupportFiles/16.0.zip b/SupportFiles/16.0.zip new file mode 100644 index 0000000..ab9a02a Binary files /dev/null and b/SupportFiles/16.0.zip differ diff --git a/SupportFiles/16.1.zip b/SupportFiles/16.1.zip new file mode 100644 index 0000000..07c9458 Binary files /dev/null and b/SupportFiles/16.1.zip differ diff --git a/SupportFiles/16.2.zip b/SupportFiles/16.2.zip new file mode 100644 index 0000000..b5f95da Binary files /dev/null and b/SupportFiles/16.2.zip differ diff --git a/SupportFiles/16.3.zip b/SupportFiles/16.3.zip new file mode 100644 index 0000000..bc4e184 Binary files /dev/null and b/SupportFiles/16.3.zip differ diff --git a/SupportFiles/16.4.zip b/SupportFiles/16.4.zip new file mode 100644 index 0000000..8a785e3 Binary files /dev/null and b/SupportFiles/16.4.zip differ diff --git a/SupportFiles/16.5.zip b/SupportFiles/16.5.zip new file mode 100644 index 0000000..fc2ec0b Binary files /dev/null and b/SupportFiles/16.5.zip differ diff --git a/SupportFiles/16.6.zip b/SupportFiles/16.6.zip new file mode 100644 index 0000000..1e4924d Binary files /dev/null and b/SupportFiles/16.6.zip differ diff --git a/SupportFiles/16.7.zip b/SupportFiles/16.7.zip new file mode 100644 index 0000000..f2a36f8 Binary files /dev/null and b/SupportFiles/16.7.zip differ diff --git a/Utils/AiUtils.py b/Utils/AiUtils.py new file mode 100644 index 0000000..ed8b40c --- /dev/null +++ b/Utils/AiUtils.py @@ -0,0 +1,1462 @@ +import html +import json +import os +import re +import signal +import socket +import subprocess +import sys +import time +import xml.etree.ElementTree as ET +from pathlib import Path +import cv2 +import numpy as np +import unicodedata +import wda +from lxml import etree +from wda import Client + +from Entity.Variables import wdaFunctionPort +from Utils.LogManager import LogManager + + +# 工具类 +class AiUtils(object): + + # 在屏幕中找到对应的图片 + # @classmethod + # def findImageInScreen(cls, target, udid): + # try: + # # 加载原始图像和模板图像 + # image_path = AiUtils.imagePathWithName(udid, "bgv") # 替换为你的图像路径 + # template_path = AiUtils.imagePathWithName("", target) # 替换为你的模板路径 + # + # # 读取图像和模板,确保它们都是单通道灰度图 + # image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) + # template = cv2.imread(template_path, cv2.IMREAD_GRAYSCALE) + # + # if image is None: + # LogManager.error("加载背景图失败") + # return -1, -1 + # + # if template is None: + # LogManager.error("加载模板图失败") + # return -1, -1 + # + # # 获取模板的宽度和高度 + # w, h = template.shape[::-1] + # + # # 使用模板匹配方法 + # res = cv2.matchTemplate(image, template, cv2.TM_CCOEFF_NORMED) + # threshold = 0.7 # 匹配度阈值,可以根据需要调整 + # loc = np.where(res >= threshold) + # + # # 检查是否有匹配结果 + # if loc[0].size > 0: + # # 取第一个匹配位置 + # pt = zip(*loc[::-1]).__next__() # 获取第一个匹配点的坐标 + # center_x = int(pt[0] + w // 2) + # center_y = int(pt[1] + h // 2) + # # print(f"第一个匹配到的小心心中心坐标: ({center_x}, {center_y})") + # return center_x, center_y + # else: + # return -1, -1 + # except Exception as e: + # LogManager.error(f"加载素材失败:{e}", udid) + # print(e) + # return -1, -1 + + @classmethod + def flask_port_free(cls,port): + """无需 psutil 的版本,通过系统命令查 PID""" + + def can_bind(p): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.bind(("0.0.0.0", p)) + s.close() + return True + except OSError: + s.close() + return False + + if can_bind(port): + return + + print(f"[ensure_port_free] Port {port} is occupied. Searching PID...") + + pids = set() + + if sys.platform.startswith("darwin") or sys.platform.startswith("linux"): + cmd = f"lsof -t -i:{port}" + proc = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + for line in proc.stdout.splitlines(): + if line.strip().isdigit(): + pids.add(int(line.strip())) + + elif sys.platform.startswith("win"): + cmd = f"netstat -ano | findstr :{port}" + proc = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + for line in proc.stdout.splitlines(): + parts = line.split() + if len(parts) >= 5 and parts[-1].isdigit(): + pids.add(int(parts[-1])) + + else: + raise RuntimeError("Unsupported platform for ensure_port_free") + + for pid in pids: + try: + print(f"[ensure_port_free] Killing PID {pid}...") + os.kill(pid, signal.SIGKILL) + except Exception as e: + print(f"[ensure_port_free] Failed to kill PID {pid}: {e}") + + time.sleep(0.3) + if not can_bind(port): + raise RuntimeError(f"[ensure_port_free] Port {port} still occupied after kill.") + + @classmethod + def findImageInScreen(cls, target, udid): + try: + # 加载原始图像和模板图像 + image_path = AiUtils.imagePathWithName(udid, "bgv") + template_path = AiUtils.imagePathWithName("", target) + + # 读取图像和模板,确保它们都是单通道灰度图 + image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) + template = cv2.imread(template_path, cv2.IMREAD_GRAYSCALE) + + if image is None: + LogManager.error("加载背景图失败", udid) + return -1, -1 + if template is None: + + LogManager.error("加载模板图失败", udid) + return -1, -1 + + # 获取模板的宽度和高度 + w, h = template.shape[::-1] + + # 模板匹配 + res = cv2.matchTemplate(image, template, cv2.TM_CCOEFF_NORMED) + threshold = 0.85 + loc = np.where(res >= threshold) + # 放在 cv2.matchTemplate 之前 + cv2.imwrite(f'/tmp/runtime_bg_{udid}.png', image) + cv2.imwrite(f'/tmp/runtime_tpl_{udid}.png', template) + print(f'>>> 设备{udid} 模板{target} 最高相似度:', cv2.minMaxLoc(res)[1]) + # 安全取出第一个匹配点 + matches = list(zip(*loc[::-1])) + if not matches: + return -1, -1 + + pt = matches[0] + center_x = int(pt[0] + w // 2) + center_y = int(pt[1] + h // 2) + return center_x, center_y + + except Exception as e: + LogManager.error(f"加载素材失败:{e}", udid) + return -1, -1 + + # 使用正则查找字符串中的数字 + @classmethod + def findNumber(cls, str): + # 使用正则表达式匹配数字 + match = re.search(r'\d+', str) + if match: + return int(match.group()) # 将匹配到的数字转换为整数 + return None # 如果没有找到数字,返回 None + + # 选择截图 + @classmethod + def screenshot(cls, session): + image = session.screenshot() + image_path = "screenshot.png" + image.save(image_path) + image = cv2.imread(image_path) + + # 如果图像过大,缩小显示 + scale_percent = 50 # 缩小比例 + width = int(image.shape[1] * scale_percent / 100) + height = int(image.shape[0] * scale_percent / 100) + dim = (width, height) + resized_image = cv2.resize(image, dim, interpolation=cv2.INTER_AREA) + + # 创建一个窗口并显示缩小后的图像 + cv2.namedWindow("Image") + cv2.imshow("Image", resized_image) + print("请在图像上选择爱心图标区域,然后按Enter键确认。") + + # 使用selectROI函数手动选择区域 + roi = cv2.selectROI("Image", resized_image, showCrosshair=True, fromCenter=False) + + # 将ROI坐标按原始图像尺寸放大 + x, y, w, h = roi + x = int(x * image.shape[1] / resized_image.shape[1]) + y = int(y * image.shape[0] / resized_image.shape[0]) + w = int(w * image.shape[1] / resized_image.shape[1]) + h = int(h * image.shape[0] / resized_image.shape[0]) + + # 根据选择的ROI提取爱心图标 + if w > 0 and h > 0: # 确保选择的区域有宽度和高度 + heart_icon = image[y:y + h, x:x + w] + + # 转换为HSV颜色空间 + hsv = cv2.cvtColor(heart_icon, cv2.COLOR_BGR2HSV) + + # 定义红色的HSV范围 + lower_red1 = np.array([0, 120, 70]) + upper_red1 = np.array([10, 255, 255]) + lower_red2 = np.array([170, 120, 70]) + upper_red2 = np.array([180, 255, 255]) + + # 创建掩模 + mask1 = cv2.inRange(hsv, lower_red1, upper_red1) + mask2 = cv2.inRange(hsv, lower_red2, upper_red2) + mask = mask1 + mask2 + + # 反转掩模,因为我们想要的是爱心图标,而不是背景 + mask_inv = cv2.bitwise_not(mask) + + # 应用掩模 + heart_icon = cv2.bitwise_and(heart_icon, heart_icon, mask=mask_inv) + + # 创建一个全透明的背景 + height, width, channels = heart_icon.shape + roi = np.zeros((height, width, channels), dtype=np.uint8) + + # 将爱心图标粘贴到透明背景上 + for c in range(channels): + roi[:, :, c] = np.where(mask_inv == 255, heart_icon[:, :, c], roi[:, :, c]) + + # 图片名称 + imgName = "temp.png" + + # 保存结果 + cv2.imwrite(imgName, roi) + + # 显示结果 + cv2.imshow("Heart Icon with Transparent Background", roi) + cv2.waitKey(0) + cv2.destroyAllWindows() + else: + print("未选择有效区域。") + + # 根据名称获取图片完整地址 + @classmethod + def imagePathWithName(cls, udid, name): + current_file_path = os.path.abspath(__file__) + # 获取当前文件所在的目录(即script目录) + current_dir = os.path.dirname(current_file_path) + # 由于script目录位于项目根目录下一级,因此需要向上一级目录移动两次 + project_root = os.path.abspath(os.path.join(current_dir, '..')) + # 构建资源文件的完整路径,向上两级目录,然后进入 resources 目录 + resource_path = os.path.abspath(os.path.join(project_root, "resources", udid, name + ".png")).replace('/', '\\') + return resource_path + + # 获取根目录 + @classmethod + def getRootDir(cls): + current_file = os.path.abspath(__file__) + # 获取当前文件所在的目录 + current_dir = os.path.dirname(current_file) + # 获取项目根目录(假设根目录是当前文件的父目录的父目录) + project_root = os.path.dirname(current_dir) + # 返回根目录 + return project_root + + # 创建一个以udid命名的目录 + @classmethod + def makeUdidDir(cls, udid): + # 获取项目根目录 + home = cls.getRootDir() + # 拼接 resources 目录的路径 + resources_dir = os.path.join(home, "resources") + # 拼接 udid 目录的路径 + udid_dir = os.path.join(resources_dir, udid) + # 检查 udid 目录是否存在,如果不存在则创建 + if not os.path.exists(udid_dir): + try: + os.makedirs(udid_dir) + LogManager.info(f"目录 {udid_dir} 创建成功", udid) + print(f"目录 {udid_dir} 创建成功") + except Exception as e: + print(f"创建目录时出错: {e}") + LogManager.error(f"创建目录时出错: {e}", udid) + else: + LogManager.info(f"目录 {udid_dir} 已存在,跳过创建", udid) + print(f"目录 {udid_dir} 已存在,跳过创建") + + # 查找首页按钮 + # uuid 设备id + # click 是否点击该按钮 + @classmethod + def findHomeButton(cls, session): + session.appium_settings({"snapshotMaxDepth": 10}) + homeButton = session.xpath("//XCUIElementTypeButton[@name='a11y_vo_home' or @label='Home' or @label='首页']") + try: + if homeButton.exists: + print("找到首页了") + return homeButton + else: + print("没找到首页") + return None + except Exception as e: + print(e) + return None + + # 查找关闭按钮 + @classmethod + def findLiveCloseButton(cls, udid="eca000fcb6f55d7ed9b4c524055214c26a7de7aa"): + client = wda.USBClient(udid,wdaFunctionPort) + session = client.session() + session.appium_settings({"snapshotMaxDepth": 10}) + r = session.xpath("//XCUIElementTypeButton[@name='关闭屏幕']") + try: + if r.label == "关闭屏幕": + return r + else: + return None + except Exception as e: + print(e) + return None + + # 获取直播间窗口数量 + @classmethod + def count_add_by_xml(cls, session): + xml = session.source() + root = ET.fromstring(xml) + return sum( + 1 for e in root.iter() + if e.get('type') in ('XCUIElementTypeButton', 'XCUIElementTypeImage') + and (e.get('name') == '添加' or e.get('label') == '添加') + and (e.get('visible') in (None, 'true')) + ) + + # 获取关注按钮 + @classmethod + def getFollowButton(cls, session: Client): + # followButton = session.xpath("//Window[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[2]/Other[2]/Other[1]/Other[1]/Other[3]/Other[1]/Other[1]/Button[1]") + session.appium_settings({"snapshotMaxDepth": 20}) + + followButton = session.xpath('//XCUIElementTypeButton[@name="关注" or @label="关注"]') + + if followButton.exists: + print("2.关注找到了") + LogManager.info("2.关注找到了") + return followButton + else: + print("2.关注没找到") + print("2.关注没找到") + return None + + # 查找发消息按钮 + @classmethod + def getSendMesageButton(cls, session: Client): + + # msgButton = session.xpath( + # '//XCUIElementTypeButton[' + # '(@name="发消息" or @label="发消息" or ' + # '@name="发送 👋" or @label="发送 👋" or ' + # '@name="消息" or @label="消息")' + # ' and @visible="true"]' + # ) + + msgButton = session.xpath( + '//XCUIElementTypeButton[' + '(@name="发消息" or @label="发消息" or ' + '@name="发送 👋" or @label="发送 👋" or ' + '@name="消息" or @label="消息" or ' + '@name="Message" or @label="Message")' + ' and @visible="true"]' + ) + + if msgButton.exists: + print("3.发消息按钮找到了") + LogManager.info("3.发消息按钮找到了") + return msgButton + else: + print("3.发消息按钮没找到") + LogManager.info("3.发消息按钮没找到") + return None + + # 获取当前屏幕上的节点 + @classmethod + def getCurrentScreenSource(cls): + client = wda.USBClient("eca000fcb6f55d7ed9b4c524055214c26a7de7aa",wdaFunctionPort) + print(client.source()) + + # 查找app主页上的收件箱按钮 + @classmethod + def getMsgBoxButton(cls, session: Client): + # box = session.xpath("//XCUIElementTypeButton[name='a11y_vo_inbox']") + box = session(xpath='//XCUIElementTypeButton[@name="a11y_vo_inbox"]') + if box.exists: + return box + else: + return None + + # 获取收件箱中的未读消息数 + @classmethod + def getUnReadMsgCount(cls, session: Client): + btn = cls.getMsgBoxButton(session) + print(f"btn:{btn}") + return cls.findNumber(btn.label) + + @classmethod + def parse_float(cls, el, attr, default=0.0): + try: + return float(el.get(attr, default)) + except Exception: + return default + + # @classmethod + # def extract_messages_from_xml(cls, xml: str): + # """ + # 解析 TikTok 聊天 XML,返回当前屏幕可见的消息与时间分隔: + # [{"type":"time","text":"..."}, {"type":"msg","dir":"in|out","text":"..."}] + # 兼容 Table / CollectionView / ScrollView;过滤系统提示/底部工具栏;可见性使用“重叠可视+容差”。 + # """ + # if not isinstance(xml, str) or not xml.strip(): + # return [] + # + # try: + # root = etree.fromstring(xml.encode("utf-8")) + # except Exception: + # return [] + # + # # ---------- 小工具 ---------- + # def get_text(el): + # s = (el.get('label') or el.get('name') or el.get('value') or '') or '' + # return html.unescape(s.strip()) + # + # def is_visible(el): + # v = el.get('visible') + # return (v is None) or (v.lower() == 'true') + # + # def get_ancestor_cell(el): + # p = el + # while p is not None and p.get('type') != 'XCUIElementTypeCell': + # p = p.getparent() + # return p + # + # # ---------- 屏幕尺寸 ---------- + # app = root.xpath('/XCUIElementTypeApplication') + # screen_w = cls.parse_float(app[0], 'width', 414.0) if app else 414.0 + # screen_h = cls.parse_float(app[0], 'height', 736.0) if app else 736.0 + # + # # ---------- 主容器探测 ---------- + # def pick_container(): + # cands = [] + # for xp, ctype in ( + # ('//XCUIElementTypeTable', 'table'), + # ('//XCUIElementTypeCollectionView', 'collection'), + # ('//XCUIElementTypeScrollView', 'scroll'), + # ): + # nodes = [n for n in root.xpath(xp) if is_visible(n)] + # for n in nodes: + # y = cls.parse_float(n, 'y', 0.0) + # h = cls.parse_float(n, 'height', screen_h) + # cells = n.xpath('.//XCUIElementTypeCell') + # score = len(cells) * 10 - abs((y + h / 2) - screen_h / 2) + # cands.append((score, n, ctype)) + # if cands: + # cands.sort(key=lambda t: t[0], reverse=True) + # return cands[0][1], cands[0][2] + # return None, None + # + # container, container_type = pick_container() + # + # # ---------- 可视区 ---------- + # if container is not None: + # area_top = cls.parse_float(container, 'y', 0.0) + # area_h = cls.parse_float(container, 'height', screen_h) + # area_bot = area_top + area_h + # else: + # blocks = [n for n in root.xpath('//XCUIElementTypeOther[@y and @height and @width>="200"]') if + # is_visible(n)] + # area_top = 0.0 + # if blocks: + # blocks.sort(key=lambda n: cls.parse_float(n, 'y', 0.0)) + # b = blocks[0] + # area_top = cls.parse_float(b, 'y', 0.0) + cls.parse_float(b, 'height', 0.0) + # tvs = [n for n in root.xpath('//XCUIElementTypeTextView') if is_visible(n)] + # if tvs: + # tvs.sort(key=lambda n: cls.parse_float(n, 'y', 0.0)) + # area_bot = cls.parse_float(tvs[-1], 'y', screen_h) + # else: + # area_bot = screen_h + # if area_bot - area_top < 100: + # area_top, area_bot = 0.0, screen_h + # + # def in_view(el) -> bool: + # if not is_visible(el): + # return False + # y = cls.parse_float(el, 'y', -1e9) + # h = cls.parse_float(el, 'height', 0.0) + # by = y + h + # tol = 8.0 + # return not (by <= area_top + tol or y >= area_bot - tol) + # + # # ---------- 时间分隔 ---------- + # items = [] + # for t in root.xpath('//XCUIElementTypeStaticText[contains(@traits, "Header")]'): + # if not in_view(t): + # continue + # txt = get_text(t) + # if txt: + # items.append({'type': 'time', 'text': txt, 'y': cls.parse_float(t, 'y', 0.0)}) + # + # # ---------- 系统提示/横幅过滤 ---------- + # EXCLUDES_LITERAL = { + # 'Heart', 'Lol', 'ThumbsUp', + # '分享发布内容', '视频贴纸标签页', '双击发送表情', '贴纸', + # '关注', + # } + # SYSTEM_PATTERNS = [ + # r"消息请求已被接受。你们可以开始聊天了。", + # r"(消息请求已被接受|你开始了和.*的聊天|你打开了这个与.*的聊天).*", + # r"开启(私信)?通知", r"开启通知", + # r"你打开了这个与 .* 的聊天。.*隐私", + # r"在此用户接受你的消息请求之前,你最多只能发送 ?\d+ 条消息。?", + # r"聊天消息条数已达上限,你将无法向该用户发送消息。?", + # r"未发送$", + # r"Turn on (DM|message|direct message)?\s*notifications", + # r"Enable notifications", + # r"Get notified when .* replies", + # r"You opened this chat .* privacy", + # r"Only \d+ message can be sent .* accepts .* request", + # r"此消息可能违反.*", + # r"无法发送", + # r"请告知我们" + # ] + # SYSTEM_RE = re.compile("|".join(SYSTEM_PATTERNS), re.IGNORECASE) + # + # # ---------- 资料卡片(个人信息)剔除 ---------- + # PROFILE_RE = re.compile( + # r"@[\w\.\-]+|粉丝|followers?|following|关注账号", + # re.IGNORECASE + # ) + # + # def is_profile_cell(cell) -> bool: + # if cell is None: + # return False + # if cell.xpath( + # './/XCUIElementTypeButton[@name="关注" or @label="关注" or ' + # 'contains(translate(@name,"FOLW","folw"),"follow") or ' + # 'contains(translate(@label,"FOLW","folw"),"follow")]' + # ): + # return True + # texts = [] + # for t in cell.xpath('.//*[@name or @label or @value]'): + # s = get_text(t) + # if s: + # texts.append(s) + # if len(texts) > 40: + # break + # joined = " ".join(texts) + # if PROFILE_RE.search(joined): + # return True + # cy = cls.parse_float(cell, 'y', 0.0) + # ch = cls.parse_float(cell, 'height', 0.0) + # if cy < area_top + 140 and ch >= 150: + # return True + # return False + # + # def is_toolbar_like(o) -> bool: + # txt = get_text(o) + # if txt in EXCLUDES_LITERAL: + # return True + # y = cls.parse_float(o, 'y', 0.0) + # h = cls.parse_float(o, 'height', 0.0) + # near_bottom = (area_bot - (y + h)) < 48 + # is_short = h <= 40 + # return near_bottom and is_short + # + # # ---------- 收集消息候选 ---------- + # msg_nodes = [] + # if container is not None: + # cand = container.xpath( + # './/XCUIElementTypeCell//*[self::XCUIElementTypeOther or self::XCUIElementTypeStaticText or self::XCUIElementTypeTextView]' + # '[@y and (@name or @label or @value)]' + # ) + # for o in cand: + # if not in_view(o): + # continue + # if is_toolbar_like(o): + # continue + # cell = get_ancestor_cell(o) + # if is_profile_cell(cell): + # continue + # txt = get_text(o) + # if not txt or SYSTEM_RE.search(txt) or txt in EXCLUDES_LITERAL: + # continue + # msg_nodes.append(o) + # else: + # cand = root.xpath( + # '//XCUIElementTypeOther[@y and (@name or @label or @value)]' + # ' | //XCUIElementTypeStaticText[@y and (@name or @label or @value)]' + # ' | //XCUIElementTypeTextView[@y and (@name or @label or @value)]' + # ) + # for o in cand: + # p = o.getparent() + # if p is not None and p.get('type') == 'XCUIElementTypeCollectionView': + # continue + # if not in_view(o) or is_toolbar_like(o): + # continue + # cell = get_ancestor_cell(o) + # if is_profile_cell(cell): + # continue + # txt = get_text(o) + # if not txt or SYSTEM_RE.search(txt) or txt in EXCLUDES_LITERAL: + # continue + # msg_nodes.append(o) + # + # # ---------- 方向判定 & 组装(中心点法) ---------- + # CENTER_MARGIN = max(12.0, screen_w * 0.02) # 中线容差 + # + # for o in msg_nodes: + # txt = get_text(o) + # if not txt or txt in EXCLUDES_LITERAL or SYSTEM_RE.search(txt): + # continue + # + # x = cls.parse_float(o, 'x', 0.0) + # y = cls.parse_float(o, 'y', 0.0) + # w = cls.parse_float(o, 'width', 0.0) + # + # center_x = x + w / 2.0 + # screen_center = screen_w / 2.0 + # + # if center_x < screen_center - CENTER_MARGIN: + # direction = 'in' # 左侧:对方 + # elif center_x > screen_center + CENTER_MARGIN: + # direction = 'out' # 右侧:自己 + # else: + # # 处在中线附近,用右缘兜底 + # right_edge = x + w + # direction = 'out' if right_edge >= screen_center else 'in' + # + # items.append({'type': 'msg', 'dir': direction, 'text': txt, 'y': y}) + # + # # ---------- 排序 & 收尾 ---------- + # if items: + # items.sort(key=lambda i: i.get('y', 0.0)) + # for it in items: + # it.pop('y', None) + # return items + + @staticmethod + def parse_float(el, key: str, default: float = 0.0) -> float: + """稳健读取浮点属性""" + if el is None: + return default + v = el.get(key) + if v is None or v == "": + return default + try: + return float(v) + except Exception: + try: + # 某些抓取会出现 '20.0px' / '20,' 等 + v2 = re.sub(r"[^\d\.\-]+", "", v) + return float(v2) if v2 else default + except Exception: + return default + + @classmethod + def extract_messages_from_xml(cls, xml: str): + """ + 解析 TikTok 聊天 XML,返回当前屏幕可见的消息与时间分隔: + [{"type":"time","text":"..."}, {"type":"msg","dir":"in|out","text":"..."}] + 兼容 Table / CollectionView / ScrollView;过滤系统提示/底部工具栏; + 资料卡只过滤“资料区块”而非整 Cell;可见性使用“重叠可视+容差”。 + """ + if not isinstance(xml, str) or not xml.strip(): + return [] + + try: + root = etree.fromstring(xml.encode("utf-8")) + except Exception: + return [] + + # ---------- 小工具 ---------- + def get_text(el): + s = (el.get('label') or el.get('name') or el.get('value') or '') or '' + return html.unescape(s.strip()) + + def is_visible(el): + v = el.get('visible') + return (v is None) or (v.lower() == 'true') + + def get_ancestor_cell(el): + p = el + while p is not None and p.get('type') != 'XCUIElementTypeCell': + p = p.getparent() + return p + + def _bbox(el): + return ( + cls.parse_float(el, 'x', 0.0), + cls.parse_float(el, 'y', 0.0), + cls.parse_float(el, 'width', 0.0), + cls.parse_float(el, 'height', 0.0), + ) + + # ---------- 屏幕尺寸 ---------- + app = root.xpath('/XCUIElementTypeApplication') + screen_w = cls.parse_float(app[0], 'width', 414.0) if app else 414.0 + screen_h = cls.parse_float(app[0], 'height', 736.0) if app else 736.0 + + # ---------- 主容器探测 ---------- + def pick_container(): + cands = [] + for xp, ctype in ( + ('//XCUIElementTypeTable', 'table'), + ('//XCUIElementTypeCollectionView', 'collection'), + ('//XCUIElementTypeScrollView', 'scroll'), + ): + nodes = [n for n in root.xpath(xp) if is_visible(n)] + for n in nodes: + y = cls.parse_float(n, 'y', 0.0) + h = cls.parse_float(n, 'height', screen_h) + cells = n.xpath('.//XCUIElementTypeCell') + score = len(cells) * 10 - abs((y + h / 2) - screen_h / 2) + cands.append((score, n, ctype)) + if cands: + cands.sort(key=lambda t: t[0], reverse=True) + return cands[0][1], cands[0][2] + return None, None + + container, container_type = pick_container() + + # ---------- 可视区 ---------- + if container is not None: + area_top = cls.parse_float(container, 'y', 0.0) + area_h = cls.parse_float(container, 'height', screen_h) + area_bot = area_top + area_h + else: + blocks = [n for n in root.xpath('//XCUIElementTypeOther[@y and @height and @width>="200"]') if + is_visible(n)] + area_top = 0.0 + if blocks: + blocks.sort(key=lambda n: cls.parse_float(n, 'y', 0.0)) + b = blocks[0] + area_top = cls.parse_float(b, 'y', 0.0) + cls.parse_float(b, 'height', 0.0) + tvs = [n for n in root.xpath('//XCUIElementTypeTextView') if is_visible(n)] + if tvs: + tvs.sort(key=lambda n: cls.parse_float(n, 'y', 0.0)) + area_bot = cls.parse_float(tvs[-1], 'y', screen_h) + else: + area_bot = screen_h + if area_bot - area_top < 100: + area_top, area_bot = 0.0, screen_h + + def in_view(el) -> bool: + if not is_visible(el): + return False + y = cls.parse_float(el, 'y', -1e9) + h = cls.parse_float(el, 'height', 0.0) + by = y + h + tol = 8.0 + return not (by <= area_top + tol or y >= area_bot - tol) + + # ---------- 时间分隔 ---------- + items = [] + for t in root.xpath('//XCUIElementTypeStaticText[contains(@traits, "Header")]'): + if not in_view(t): + continue + txt = get_text(t) + if txt: + items.append({'type': 'time', 'text': txt, 'y': cls.parse_float(t, 'y', 0.0)}) + + # ---------- 系统提示/横幅过滤 ---------- + EXCLUDES_LITERAL = { + 'Heart', 'Lol', 'ThumbsUp', + '分享发布内容', '视频贴纸标签页', '双击发送表情', '贴纸', + '关注', # 注意:仅用于按钮/工具条等短元素,后续还会叠加区域过滤,避免误杀消息 + } + SYSTEM_PATTERNS = [ + r"消息请求已被接受。你们可以开始聊天了。", + r"(消息请求已被接受|你开始了和.*的聊天|你打开了这个与.*的聊天).*", + r"开启(私信)?通知", r"开启通知", + r"你打开了这个与 .* 的聊天。.*隐私", + r"在此用户接受你的消息请求之前,你最多只能发送 ?\d+ 条消息。?", + r"聊天消息条数已达上限,你将无法向该用户发送消息。?", + r"未发送$", + r"Turn on (DM|message|direct message)?\s*notifications", + r"Enable notifications", + r"Get notified when .* replies", + r"You opened this chat .* privacy", + r"Only \d+ message can be sent .* accepts .* request", + r"此消息可能违反.*", + r"无法发送", + r"请告知我们", + r"已看过" + ] + SYSTEM_RE = re.compile("|".join(SYSTEM_PATTERNS), re.IGNORECASE) + + # ---------- 资料卡片(个人信息)剔除:仅过滤“资料区块” ---------- + PROFILE_RE = re.compile( + r"@[\w\.\-]+|粉丝|followers?|following|关注账号", + re.IGNORECASE + ) + + def is_profile_cell(cell) -> bool: + """更严格:至少同时命中 >=2 个信号才认定为资料卡片 Cell。""" + if cell is None: + return False + + has_follow_btn = bool(cell.xpath( + './/XCUIElementTypeButton[' + '@name="关注" or @label="关注" or ' + 'contains(translate(@name,"FOLW","folw"),"follow") or ' + 'contains(translate(@label,"FOLW","folw"),"follow")]' + )) + + has_view_profile = bool(cell.xpath( + './/XCUIElementTypeButton[' + '@name="查看主页" or @label="查看主页" or ' + 'contains(translate(@name,"VIEW PROFILE","view profile"),"view profile") or ' + 'contains(translate(@label,"VIEW PROFILE","view profile"),"view profile")]' + )) + + has_live_ended = bool(cell.xpath( + './/XCUIElementTypeStaticText[' + '@name="直播已结束" or @label="直播已结束" or ' + 'contains(translate(@name,"LIVE ENDED","live ended"),"live ended") or ' + 'contains(translate(@label,"LIVE ENDED","live ended"),"live ended")]' + )) + + cy = cls.parse_float(cell, 'y', 0.0) + ch = cls.parse_float(cell, 'height', 0.0) + looks_large_card = ch >= 180 # 大卡片外观 + + # 再做一次文本特征检查(防止仅一个“关注”误杀) + texts = [] + for t in cell.xpath('.//*[@name or @label or @value]'): + s = get_text(t) + if s: + texts.append(s) + if len(texts) > 40: + break + joined = " ".join(texts) + has_profile_terms = bool(PROFILE_RE.search(joined)) + + # 命中信号计数(至少2个) + signals = sum([has_follow_btn, has_view_profile, has_live_ended, looks_large_card, has_profile_terms]) + return signals >= 2 + + def profile_region_y_range(cell): + """ + 在资料卡 Cell 内,估算“资料区块”的 y 范围(min_y, max_y)。 + 用关键元素(关注按钮 / 查看主页 / 直播已结束 / 短用户名)来圈定范围。 + """ + if cell is None: + return None + + key_nodes = [] + key_nodes += cell.xpath('.//XCUIElementTypeButton[@name="关注" or @label="关注"]') + key_nodes += cell.xpath('.//XCUIElementTypeButton[@name="查看主页" or @label="查看主页"]') + key_nodes += cell.xpath('.//XCUIElementTypeStaticText[@name="直播已结束" or @label="直播已结束"]') + + # 用户名/昵称:长度较短更像资料区标签 + for t in cell.xpath('.//XCUIElementTypeStaticText[@name or @label]'): + s = (t.get('label') or t.get('name') or '') or '' + st = s.strip() + if st and len(st) <= 30: + key_nodes.append(t) + + ys = [] + for n in key_nodes: + _, y, _, h = _bbox(n) + ys += [y, y + h] + + if not ys: + return None # 没有关键元素则不定义资料区 + + min_y, max_y = min(ys), max(ys) + pad = 12.0 + return (min_y - pad, max_y + pad) + + def belongs_to_profile_region(node, cell) -> bool: + """判断候选 node 是否落在资料区块的 y 范围内""" + rng = profile_region_y_range(cell) + if not rng: + return False + _, y, _, h = _bbox(node) + ny1, ny2 = y, y + h + ry1, ry2 = rng + return not (ny2 < ry1 or ny1 > ry2) # 任意重叠即算属于资料区 + + def is_toolbar_like(o) -> bool: + # 在 Cell 里的元素绝不是底部工具条(避免误杀“hgh”这类贴着底部的最后一条消息) + if get_ancestor_cell(o) is not None: + return False + + txt = get_text(o) + if txt in EXCLUDES_LITERAL: + return True + + y = cls.parse_float(o, 'y', 0.0) + h = cls.parse_float(o, 'height', 0.0) + near_bottom = (area_bot - (y + h)) < 48 + is_short = h <= 40 + return near_bottom and is_short + + # ---------- 收集消息候选 ---------- + msg_nodes = [] + if container is not None: + cand = container.xpath( + './/XCUIElementTypeCell//*[self::XCUIElementTypeOther or self::XCUIElementTypeStaticText or self::XCUIElementTypeTextView]' + '[@y and (@name or @label or @value)]' + ) + for o in cand: + if not in_view(o): + continue + if is_toolbar_like(o): + continue + cell = get_ancestor_cell(o) + # 仅在“资料卡 Cell 且节点位于资料区块范围内”时过滤 + if is_profile_cell(cell) and belongs_to_profile_region(o, cell): + continue + txt = get_text(o) + if not txt or SYSTEM_RE.search(txt) or txt in EXCLUDES_LITERAL: + continue + msg_nodes.append(o) + else: + cand = root.xpath( + '//XCUIElementTypeOther[@y and (@name or @label or @value)]' + ' | //XCUIElementTypeStaticText[@y and (@name or @label or @value)]' + ' | //XCUIElementTypeTextView[@y and (@name or @label or @value)]' + ) + for o in cand: + p = o.getparent() + if p is not None and p.get('type') == 'XCUIElementTypeCollectionView': + continue + if not in_view(o) or is_toolbar_like(o): + continue + cell = get_ancestor_cell(o) + if is_profile_cell(cell) and belongs_to_profile_region(o, cell): + continue + txt = get_text(o) + if not txt or SYSTEM_RE.search(txt) or txt in EXCLUDES_LITERAL: + continue + msg_nodes.append(o) + + # ---------- 方向判定 & 组装(中心点法) ---------- + CENTER_MARGIN = max(12.0, screen_w * 0.02) # 中线容差 + + for o in msg_nodes: + txt = get_text(o) + if not txt or txt in EXCLUDES_LITERAL or SYSTEM_RE.search(txt): + continue + + x = cls.parse_float(o, 'x', 0.0) + y = cls.parse_float(o, 'y', 0.0) + w = cls.parse_float(o, 'width', 0.0) + + center_x = x + w / 2.0 + screen_center = screen_w / 2.0 + + if center_x < screen_center - CENTER_MARGIN: + direction = 'in' # 左侧:对方 + elif center_x > screen_center + CENTER_MARGIN: + direction = 'out' # 右侧:自己 + else: + # 处在中线附近,用右缘兜底 + right_edge = x + w + direction = 'out' if right_edge >= screen_center else 'in' + + items.append({'type': 'msg', 'dir': direction, 'text': txt, 'y': y}) + + # ---------- 排序 & 收尾 ---------- + if items: + items.sort(key=lambda i: i.get('y', 0.0)) + for it in items: + it.pop('y', None) + return items + + @classmethod + def get_navbar_anchor_name(cls, session, timeout: float = 5) -> str: + """从聊天页导航栏读取主播名称;找不到返回空字符串。""" + + # 限制快照深度(可选) + try: + session.appium_settings({"snapshotMaxDepth": 22}) + except Exception: + pass + + def _text_of(el) -> str: + info = getattr(el, "info", {}) or {} + return (info.get("label") or info.get("name") or info.get("value") or "").strip() + + def _clean_tail(s: str) -> str: + # 去掉末尾中英标点和空白(TikTok 昵称右侧常带一个顿号/逗号,比如 “Alina,”) + return re.sub(r"[,、,。.\s]+$", "", s).strip() + + # ---- 关键修复:导航容器 ---- + # 1) “返回”可能是 Button 也可能是 Other;同时页面右上角有 “更多/举报” 按钮 + BACK_ELEM = ( + "//*[@type='XCUIElementTypeButton' or @type='XCUIElementTypeOther']" + "[@name='返回' or @label='返回' or @name='Back' or @label='Back' " + " or @name='戻る' or @label='戻る']" + ) + RIGHT_MENU_BTN = ( + "//XCUIElementTypeButton" + "[@name='更多' or @label='更多' or @name='More' or @label='More' or " + " @name='その他' or @label='その他' or @name='詳細' or @label='詳細' or " + " @name='举报' or @label='举报' or @name='Report' or @label='Report' or " + " @name='報告' or @label='報告']" + ) + # 从“返回”向上找到最近祖先 Other,且该祖先内包含“更多/举报”按钮 + NAV_CONTAINER = ( + BACK_ELEM + + "/ancestor::XCUIElementTypeOther[ .//XCUIElementTypeOther or .//XCUIElementTypeButton ][ ." + + RIGHT_MENU_BTN + + "][1]" + ) + + # ① 优先:在导航容器里找“可访问的 Other(有文本且不包含子 Button)” + XPATH_TITLE_OTHER = ( + NAV_CONTAINER + + "//XCUIElementTypeOther[@accessible='true' and count(.//XCUIElementTypeButton)=0 " + " and (string-length(@name)>0 or string-length(@label)>0 or string-length(@value)>0)][1]" + ) + + # ② 退路:导航容器内第一个有文本的 StaticText + XPATH_TITLE_STATIC = ( + NAV_CONTAINER + + "//XCUIElementTypeStaticText[string-length(@value)>0 or string-length(@label)>0 or string-length(@name)>0][1]" + ) + + # ③ 兜底:直接在“返回”与右侧菜单同一层级/附近范围内找可读的 Other(适配部分机型结构差异) + XPATH_FALLBACK_NEAR_BACK = ( + BACK_ELEM + + "/ancestor::XCUIElementTypeOther[1]" + "//XCUIElementTypeOther[@accessible='true' and " + " (string-length(@name)>0 or string-length(@label)>0 or string-length(@value)>0)]" + "[count(.//XCUIElementTypeButton)=0][1]" + ) + + # ④ 兜底:昵称往往以顿号/逗号结尾(例如 “Alina,”);利用这个规律匹配 + XPATH_HINT_COMMA_END = ( + NAV_CONTAINER + + "//*[ (contains(@name,',') or contains(@label,',') or contains(@value,',')) " + " and string-length(@name)+string-length(@label)+string-length(@value) < 64 ]" + "[1]" + ) + + # ---- 查询顺序:① -> ② -> ③ -> ④ ---- + for xp, wait_s in [ + (XPATH_TITLE_OTHER, timeout), + (XPATH_TITLE_STATIC, 1.0), + (XPATH_FALLBACK_NEAR_BACK, 1.0), + (XPATH_HINT_COMMA_END, 1.0), + ]: + try: + q = session.xpath(xp) + if q.wait(wait_s): + txt = _clean_tail(_text_of(q.get())) + if txt: + return txt + except Exception: + pass + + return "" + + # 检查字符串中是否包含中文 + @classmethod + def contains_chinese(cls, text): + """ + 判断字符串中是否包含中文字符。 + 参数: + text (str): 要检测的字符串。 + + 返回: + bool: 如果字符串中包含中文,返回 True;否则返回 False。 + """ + # 使用正则表达式匹配中文字符 + pattern = re.compile(r'[\u4e00-\u9fff]') + return bool(pattern.search(text)) + + @classmethod + def is_language(cls, text: str) -> bool: + if not text: + return False + for ch in text: + if unicodedata.category(ch).startswith("L"): + return True + return False + + @classmethod + def _read_json_list(cls, file_path: Path) -> list: + """读取为 list;读取失败或不是 list 则返回空数组""" + if not file_path.exists(): + return [] + try: + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, list) else [] + except Exception as e: + LogManager.error(f"[acList] 读取失败,将按空数组处理: {e}") + return [] + + @classmethod + def _write_json_list(cls, file_path: Path, data: list) -> None: + file_path.parent.mkdir(parents=True, exist_ok=True) + with open(file_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + @staticmethod + def _normalize_anchor_items(items): + """ + 规范化为 [{...}]: + - 允许传入:单个对象、对象数组、字符串(当 anchorId 用) + - 保留原有字段,不限制只存 anchorId/country + - 字符串输入 → {"anchorId": xxx} + """ + result = [] + if items is None: + return result + + if isinstance(items, dict): + aid = items.get("anchorId") + if aid: + obj = dict(items) # 保留所有字段 + result.append(obj) + return result + + if isinstance(items, list): + for it in items: + if isinstance(it, dict): + aid = it.get("anchorId") + if aid: + obj = dict(it) + result.append(obj) + elif isinstance(it, str): + result.append({"anchorId": it}) + return result + + if isinstance(items, str): + result.append({"anchorId": items}) + return result + + # -------- 追加(对象数组平铺追加) -------- + @classmethod + def save_aclist_flat_append(cls, acList, filename="data/acList.json"): + """ + 将 anchor 对象数组平铺追加到 JSON 文件(数组)中。 + 文件固定写到 项目根目录/data/acList.json + """ + + # 找到当前文件所在目录,回退到项目根目录 + root_dir = Path(__file__).resolve().parent.parent # 根据实际层级调整 + log_dir = root_dir + log_dir.mkdir(parents=True, exist_ok=True) # 确保 log 目录存在 + + file_path = log_dir / filename + + data = cls._read_json_list(file_path) + + # 规范化输入,确保都是 {anchorId, country} + to_add = cls._normalize_anchor_items(acList) + if not to_add: + LogManager.info("[acList] 传入为空或不合规,跳过写入") + return + + data.extend(to_add) + cls._write_json_list(file_path, data) + # LogManager.method_info(f"写入的路径是:{file_path}", "写入数据") + LogManager.info(f"[acList] 已追加 {len(to_add)} 条,当前总数={len(data)} -> {file_path}") + + @classmethod + def pop_aclist_first(cls, filename="data/acList.json", mode="pop"): + """ + 从 JSON 数组/对象(anchorList)中取出第一个 anchor 对象。 + - mode="pop" : 取出并删除 + - mode="move" : 取出并放到列表尾部 + 返回形如:{"anchorId": "...", "country": "...", ...} + """ + file_path = cls._resolve_path(filename) + + + if not file_path.exists(): + return None + + try: + raw = json.loads(file_path.read_text(encoding="utf-8-sig")) + except Exception as e: + LogManager.error(f"[pop_aclist_first] 读取失败: {e}") + return None + + # 支持两种格式:list 或 dict{anchorList:[...]} + if isinstance(raw, list): + data, wrapper = raw, None + elif isinstance(raw, dict) and isinstance(raw.get("anchorList"), list): + data, wrapper = raw["anchorList"], raw + else: + return None + + if not data: + return None + + # 取第一个 + first = data.pop(0) + norm = cls._normalize_anchor_items(first) + first = norm[0] if norm else None + + if first and mode == "move": + # 放到尾部 + data.append(first) + + # 写回 + to_write = wrapper if wrapper is not None else data + file_path.write_text( + json.dumps(to_write, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + return first + + @classmethod + def bulk_update_anchors(cls, updates, filename="data/acList.json", case_insensitive=False): + """ + 批量修改(文件根必须是数组,沿用 _read_json_list 的约定) + - updates: + dict: {"id1": {...}, "id2": {...}} + list[dict]: [{"anchorId":"id1", ...}, {"anchorId":"id2", ...}] + - case_insensitive: True 时用小写比较 anchorId + 返回: {"updated": , "missing": [ids...], "file": "<实际命中的路径>"} + """ + + def norm_id(x: str) -> str: + s = str(x).strip() + return s.lower() if case_insensitive else s + + # ✅ 关键:使用你已有的 _resolve_path,避免受 cwd 影响 + file_path = cls._resolve_path(filename) + + data = cls._read_json_list(file_path) + if not data: + return {"updated": 0, "missing": cls._collect_all_ids(updates), "file": str(file_path)} + + # 1) 归一化 updates -> map[normalized_id] = patch + upd_map = {} + raw_ids = [] # 保留原始传入 id,用于返回 missing 时回显 + if isinstance(updates, dict): + for aid, patch in updates.items(): + if aid and isinstance(patch, dict): + key = norm_id(aid) + raw_ids.append(str(aid)) + patch = {k: v for k, v in patch.items() if k != "anchorId"} + if patch: + upd_map[key] = {**upd_map.get(key, {}), **patch} + elif isinstance(updates, list): + for it in updates: + if isinstance(it, dict) and it.get("anchorId"): + rid = str(it["anchorId"]) + key = norm_id(rid) + raw_ids.append(rid) + patch = {k: v for k, v in it.items() if k != "anchorId"} + if patch: + upd_map[key] = {**upd_map.get(key, {}), **patch} + + if not upd_map: + return {"updated": 0, "missing": [], "file": str(file_path)} + + # 2) 建索引:map[normalized_id] -> item + index = {} + for item in data: + if isinstance(item, dict) and "anchorId" in item: + key = norm_id(item.get("anchorId", "")) + if key: + index[key] = item + + # 3) 执行更新 + updated, seen = 0, set() + for key, patch in upd_map.items(): + target = index.get(key) + if target is not None: + target.update(patch) + updated += 1 + seen.add(key) + + # 4) 写回 + if updated > 0: + cls._write_json_list(file_path, data) + + # 5) 计算未命中(按传入原始 ID 回显) + missing = [] + for rid in raw_ids: + if norm_id(rid) not in seen: + missing.append(rid) + + return {"updated": updated, "missing": missing, "file": str(file_path)} + + @staticmethod + def _collect_all_ids(updates): + ids = [] + if isinstance(updates, dict): + ids = [str(k) for k in updates.keys()] + elif isinstance(updates, list): + for it in updates: + if isinstance(it, dict) and it.get("anchorId"): + ids.append(str(it["anchorId"])) + return ids + + @classmethod + def delete_anchors_by_ids(cls, ids: list[str], filename: str = "acList.json") -> int: + """ + 根据 anchorId 列表,从项目根目录/data/acList.json 中删除匹配的 anchor。 + - ids: 要删除的 anchorId 列表 + - filename: 默认为 acList.json,可以传文件名或绝对路径 + 返回:删除数量 + """ + # 计算文件路径 + root_dir = Path(__file__).resolve().parent.parent + if Path(filename).is_absolute(): + file_path = Path(filename) + else: + file_path = root_dir / "data" / filename + + if not file_path.exists(): + return 0 + + # 读取 JSON 文件 + try: + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, list): + return 0 + except Exception as e: + LogManager.error(f"[delete_anchors_by_ids] 读取失败: {e}") + return 0 + + before = len(data) + + # 保留不在 ids 里的对象 + data = [d for d in data if isinstance(d, dict) and d.get("anchorId") not in ids] + deleted = before - len(data) + + # 写回 JSON 文件 + try: + file_path.parent.mkdir(parents=True, exist_ok=True) + with open(file_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + except Exception as e: + LogManager.error(f"[delete_anchors_by_ids] 写入失败: {e}") + + return deleted + + # -------- 查看第一个(取出但不删除) -------- + @staticmethod + def _resolve_path(p) -> Path: + p = Path(p) + if p.is_absolute(): + return p + # 以项目根目录 (iOSAI) 为基准 —— script 的上一级 + base = Path(__file__).resolve().parents[1] + return (base / p).resolve() + + @classmethod + def peek_aclist_first(cls, filename="data/acList.json"): + file_path = cls._resolve_path(filename) + if not file_path.exists(): + print(f"[peek] 文件不存在: {file_path}") + return None + try: + raw = file_path.read_text(encoding="utf-8-sig").strip() + if not raw: + return None + data = json.loads(raw) + arr = data if isinstance(data, list) else data.get("anchorList") if isinstance(data, dict) else None + if not arr: + return None + first = arr[0] + norm = cls._normalize_anchor_items(first) + return norm[0] if norm else None + except Exception as e: + print(f"[peek] 读取失败: {e}") + return None + + @staticmethod + def run_tidevice_command(udid, action, bundle_id, timeout=30): + """ + 执行tidevice命令的辅助函数 + + :param udid: 设备UDID + :param action: 动作类型 ('kill' 或 'launch') + :param bundle_id: 应用的Bundle ID + :param timeout: 命令执行超时时间(秒) + :return: (bool) 成功返回True,失败返回False + """ + # 构建命令列表 + cmd = ["tidevice", "--udid", udid, action, bundle_id] + try: + # 执行命令并捕获输出 + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout + ) + # 检查命令是否成功执行(返回码为0通常表示成功) + if result.returncode == 0: + LogManager.info(f"Successfully {action}ed {bundle_id} on device {udid}.") + return True + else: + # 记录错误信息 + LogManager.error(f"Failed to {action} {bundle_id} on device {udid}. Error: {result.stderr}") + return False + + except subprocess.TimeoutExpired: + # 处理命令执行超时 + LogManager.error(f"Command 'tidevice {action}' timed out after {timeout} seconds for device {udid}.") + return False + except FileNotFoundError: + # 处理tidevice命令未找到的情况(通常意味着tidevice未安装或不在PATH中) + LogManager.error( + "The 'tidevice' command was not found. Please ensure it is installed and in your system PATH.") + return False + except Exception as e: + # 捕获其他可能异常 + LogManager.error(f"An unexpected error occurred while trying to {action} the app: {e}") + return False + + @classmethod + def kill_wda(cls, udid, bundle_id="com.yolozsAgent.wda.xctrunner"): + """ + 杀死指定设备上的WDA应用 + + :param udid: 设备UDID + :param bundle_id: WDA的Bundle ID,默认为 com.yolozsAgent.wda.xctrunner + :return: (bool) 成功返回True,失败返回False + """ + return cls.run_tidevice_command(udid, "kill", bundle_id) + + @classmethod + def launch_wda(cls, udid, bundle_id="com.yolozsAgent.wda.xctrunner", timeout=60): + """ + 启动指定设备上的WDA应用 + + :param udid: 设备UDID + :param bundle_id: WDA的Bundle ID,默认为 com.yolozsAgent.wda.xctrunner + :param timeout: 启动命令超时时间,默认为60秒(启动可能较慢) + :return: (bool) 成功返回True,失败返回False + """ + return cls.run_tidevice_command(udid, "launch", bundle_id, timeout) + + + + @classmethod + def _screen_info(cls, udid: str): + try: + # 避免 c.home() 可能触发的阻塞,直接取 window_size + c = wda.USBClient(udid, wdaFunctionPort) + size = c.window_size() + print(f"[Screen] 成功获取屏幕 {int(size.width)}x{int(size.height)} {udid}") + return int(size.width), int(size.height), float(c.scale) + except Exception as e: + print(f"[Screen] 获取屏幕信息异常: {e} {udid}") + return 0, 0, 0.0 \ No newline at end of file diff --git a/Utils/ControlUtils.py b/Utils/ControlUtils.py new file mode 100644 index 0000000..cbbe1e7 --- /dev/null +++ b/Utils/ControlUtils.py @@ -0,0 +1,356 @@ +import math +import random +import re +import time +from typing import Tuple, List +import tidevice +import wda +from wda import Client + +from Entity.Variables import wdaFunctionPort +from Utils.AiUtils import AiUtils +from Utils.LogManager import LogManager + + +# 页面控制工具类 +class ControlUtils(object): + + # 获取设备上的app列表 + @classmethod + def getDeviceAppList(self, udid): + device = tidevice.Device(udid) + # 获取已安装的应用列表 + apps = [] + for app in device.installation.iter_installed(): + apps.append({ + "name": app.get("CFBundleDisplayName", "Unknown"), + "bundleId": app.get("CFBundleIdentifier", "Unknown"), + "version": app.get("CFBundleShortVersionString", "Unknown"), + "path": app.get("Path", "Unknown") + }) + + # 筛选非系统级应用(过滤掉以 com.apple 开头的系统应用) + noSystemApps = [app for app in apps if not app["bundleId"].startswith("com.apple")] + return noSystemApps + + # 打开Tik Tok + @classmethod + def openTikTok(cls, session: Client, udid): + apps = cls.getDeviceAppList(udid) + tk = "" + for app in apps: + if app.get("name", "") == "TikTok": + tk = app.get("bundleId", "") + + currentApp = session.app_current() + if currentApp != tk: + session.app_start(tk) + + # 关闭Tik Tok + @classmethod + def closeTikTok(cls, session: Client, udid): + apps = cls.getDeviceAppList(udid) + tk = "" + for app in apps: + if app.get("name", "") == "TikTok": + tk = app.get("bundleId", "") + session.app_stop(tk) + + # 返回 + @classmethod + def clickBack(cls, session: Client): + try: + + back = session.xpath( + # ① 常见中文文案 + "//*[@label='返回' or @label='返回上一屏幕']" + " | " + # ② 英文 / 内部 name / 图标 label 的按钮(仅限 Button,且可见) + "//XCUIElementTypeButton[@visible='true' and (" + "@name='Back' or @label='Back' or " # 英文 + "@name='返回' or @label='返回' or " # 中文 + "@label='返回上一屏幕' or " # 中文另一种 + "@name='returnButton' or" + "@name='nav_bar_start_back' or " # 内部常见 name + "(@name='TTKProfileNavBarBaseItemComponent' and @label='IconChevronLeftOffsetLTR')" # 你给的特例 + ")]" + ) + + if back.exists: + back.click() + return True + elif session.xpath("//*[@name='nav_bar_start_back']").exists: + back = session.xpath("//*[@name='nav_bar_start_back']") + if back.exists: + back.click() + return True + elif session.xpath( + "//Window[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]").exists: + back = session.xpath( + "//Window[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]") + if back.exists: + back.click() + return True + elif session.xpath( + "(//XCUIElementTypeOther[@y='20' and @height='44']//XCUIElementTypeButton[@visible='true'])[1]").exists: + back = session.xpath( + "(//XCUIElementTypeOther[@y='20' and @height='44']//XCUIElementTypeButton[@visible='true'])[1]") + if back.exists: + back.click() + return True + else: + return False + except Exception as e: + print(e) + return False + + @classmethod + def isClickBackEnabled(cls, session: Client): + try: + + back = session.xpath( + # ① 常见中文文案 + "//*[@label='返回' or @label='返回上一屏幕']" + " | " + # ② 英文 / 内部 name / 图标 label 的按钮(仅限 Button,且可见) + "//XCUIElementTypeButton[@visible='true' and (" + "@name='Back' or @label='Back' or " # 英文 + "@name='返回' or @label='返回' or " # 中文 + "@label='返回上一屏幕' or " # 中文另一种 + "@name='returnButton' or" + "@name='nav_bar_start_back' or " # 内部常见 name + "(@name='TTKProfileNavBarBaseItemComponent' and @label='IconChevronLeftOffsetLTR')" # 你给的特例 + ")]" + ) + + if back.exists: + return True + elif session.xpath("//*[@name='nav_bar_start_back']").exists: + back = session.xpath("//*[@name='nav_bar_start_back']") + if back.exists: + return True + elif session.xpath( + "//Window[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]").exists: + back = session.xpath( + "//Window[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]/Other[1]") + if back.exists: + return True + elif session.xpath( + "(//XCUIElementTypeOther[@y='20' and @height='44']//XCUIElementTypeButton[@visible='true'])[1]").exists: + back = session.xpath( + "(//XCUIElementTypeOther[@y='20' and @height='44']//XCUIElementTypeButton[@visible='true'])[1]") + if back.exists: + return True + else: + return False + except Exception as e: + print(e) + return False + + # 点赞 + @classmethod + def clickLike(cls, session: Client, udid): + try: + from script.ScriptManager import ScriptManager + + width, height, scale = ScriptManager.get_screen_info(udid) + + if scale == 3.0: + x, y = AiUtils.findImageInScreen("add", udid) + if x > -1: + LogManager.method_info(f"点赞了,点赞的坐标是:{x // scale, y // scale + 50}", "关注打招呼", udid) + session.click(int(x // scale), int(y // scale + 50)) + return True + else: + LogManager.method_info("没有找到目标", "关注打招呼", udid) + return False + else: + x, y = AiUtils.findImageInScreen("like1", udid) + if x > -1: + LogManager.method_info(f"点赞了,点赞的坐标是:{x // scale, y // scale}", "关注打招呼", udid) + session.click(int(x // scale), int(y // scale)) + return True + else: + LogManager.method_info("没有找到目标", "关注打招呼", udid) + return False + + + + except Exception as e: + LogManager.method_info(f"点赞出现异常,异常的原因:{e}", "关注打招呼", udid) + raise False + + # 点击搜索 + @classmethod + def clickSearch(cls, session: Client): + # obj = session.xpath("//*[@name='搜索']") + obj = session(xpath='//*[@name="搜索" or @label="搜索" or @name="Search" or @label="Search"]') + try: + if obj.exists: + obj.click() + return True + except Exception as e: + print(e) + return False + + # 点击收件箱按钮 + @classmethod + def clickMsgBox(cls, session: Client): + box = session.xpath("//XCUIElementTypeButton[name='a11y_vo_inbox']") + if box.exists: + box.click() + return True + else: + return False + + # 获取主播详情页的第一个视频 + @classmethod + def clickFirstVideoFromDetailPage(cls, session: Client): + + videoCell = session.xpath( + '(//XCUIElementTypeCollectionView//XCUIElementTypeCell[.//XCUIElementTypeImage[@name="profile_video"]])[1]') + + tab = session.xpath( + '//XCUIElementTypeButton[@name="TTKProfileTabVideoButton_0" or contains(@label,"作品") or contains(@name,"作品")]' + ).get(timeout=5) # 某些版本 tab.value 可能就是数量;或者 tab.label 类似 “作品 7” + m = re.search(r"\d+", tab.label) + + num = 0 + + if m: + # 判断当前的作品的数量 + num = int(m.group()) + print("作品数量为:", num) + + if videoCell.exists: + videoCell.click() + # 点击视频 + print("找到主页的第一个视频") + return True, num + else: + print("没有找到主页的第一个视频") + return False, num + + @classmethod + def clickFollow(cls, session, aid): + # 1) 含“关注/已关注/Follow/Following”的首个 cell + cell_xpath = ( + '(//XCUIElementTypeCollectionView[@name="TTKSearchCollectionComponent"]' + '//XCUIElementTypeCell[.//XCUIElementTypeButton[@name="关注" or @name="Follow" or @name="已关注" or @name="Following"]])[1]' + ) + cell = session.xpath(cell_xpath).get(timeout=5) + + # 2) 先试“用户信息 Button”(label/name 里包含 aid) + profile_btn_xpath = ( + f'{cell_xpath}//XCUIElementTypeButton[contains(@label, "{aid}") or contains(@name, "{aid}")]' + ) + + try: + profile_btn = session.xpath(profile_btn_xpath).get(timeout=3) + profile_btn.click() + except wda.WDAElementNotFoundError: + # 3) 兜底:用“关注”按钮做锚点,向左偏移点击头像/用户名区域 + follow_btn_xpath = ( + f'{cell_xpath}//XCUIElementTypeButton[@name="关注" or @name="Follow" or @name="已关注" or @name="Following"]' + ) + follow_btn = session.xpath(follow_btn_xpath).get(timeout=5) + rect = follow_btn.bounds + left_x = max(1, rect.x - 20) + center_y = rect.y + rect.height // 2 + session.tap(left_x, center_y) + + @classmethod + def userClickProfile(cls, session, aid): + try: + user_btn = session.xpath("(//XCUIElementTypeButton[@name='用户' and @visible='true'])[1]") + if user_btn.exists: + user_btn.click() + time.sleep(3) + follow_btn = session.xpath( + "(//XCUIElementTypeTable//XCUIElementTypeButton[@name='关注' or @name='已关注'])[1]" + ).get(timeout=5) + if follow_btn: + x, y, w, h = follow_btn.bounds + # 垂直方向中心 + 随机 3~8 像素偏移 + cy = int(y + h / 2 + random.randint(-8, 8)) + # 横向往左偏移 80~120 像素之间的随机值 + cx = int(x - random.randint(80, 120)) + # 点击 + session.tap(cx, cy) + return True + + return False + except Exception as e: + print(e) + return False + + @classmethod + def random_micro_swipe( + cls, + center_x: int, + center_y: int, + session, + points: int = 6, + duration_ms: int = 15, + ) -> None: + """ + 在 (center_x, center_y) 附近做 20px 左右的不规则微滑动。 + 使用 facebook-wda 的 session.swipe(x1, y1, x2, y2, duration) 接口。 + """ + # 1. 随机方向 + angle = random.uniform(0, 2 * math.pi) + length = random.uniform(18, 22) # 20px 左右 + end_x = center_x + length * math.cos(angle) + end_y = center_y + length * math.sin(angle) + + # 2. 限制在 20px 圆内(防止超出) + def clamp_to_circle(x, y, cx, cy, r): + dx = x - cx + dy = y - cy + if dx * dx + dy * dy > r * r: + scale = r / math.hypot(dx, dy) + x = cx + dx * scale + y = cy + dy * scale + return int(round(x)), int(round(y)) + + end_x, end_y = clamp_to_circle(end_x, end_y, center_x, center_y, 20) + + # 3. 加入轻微噪声,制造“不规则”曲线 + noise = 3 # 最大偏移像素 + mid_count = points - 2 + mid_points: List[Tuple[int, int]] = [] + for i in range(1, mid_count + 1): + t = i / (mid_count + 1) + # 线性插值 + 垂直方向噪声 + x = center_x * (1 - t) + end_x * t + y = center_y * (1 - t) + end_y * t + perp_angle = angle + math.pi / 2 # 垂直方向 + offset = random.uniform(-noise, noise) + x += offset * math.cos(perp_angle) + y += offset * math.sin(perp_angle) + x, y = clamp_to_circle(x, y, center_x, center_y, 20) + mid_points.append((int(round(x)), int(round(y)))) + + # 4. 构造完整轨迹 + trajectory: List[Tuple[int, int]] = ( + [(center_x, center_y)] + mid_points + [(end_x, end_y)] + ) + + # 5. 使用 facebook-wda 的 swipe 接口(逐段 swipe) + # 由于总时长太短,我们一次性 swipe 到终点,但用多点轨迹模拟 + # facebook-wda 支持 swipe(x1, y1, x2, y2, duration) + # 我们直接用起点 -> 终点,duration 用总时长 + print("开始微滑动") + session.swipe(center_x, center_y, end_x, end_y, duration_ms / 1000) + print("随机微滑动:", trajectory) + + # 向上滑动 脚本内部使用 + @classmethod + def swipe_up(cls, client): + client.swipe(200, 350, 200, 250, 0.05) + + # 向下滑动,脚本内使用 + @classmethod + def swipe_down(cls, udid): + dev = wda.USBClient(udid, wdaFunctionPort) + dev.swipe(200, 250, 200, 350, 0.05) diff --git a/Utils/CountryEnum.py b/Utils/CountryEnum.py new file mode 100644 index 0000000..5a0855c --- /dev/null +++ b/Utils/CountryEnum.py @@ -0,0 +1,271 @@ +class CountryLanguageMapper: + + # 初始化一个字典,映射国家到语言代码 + country_to_language = { + "中国大陆": "zh-CN", + "台湾": "zh-TW", + "香港": "zh-TW", + "澳门": "zh-TW", + "美国": "en", + "英国": "en", + "澳大利亚": "en", + "日本": "ja", + "韩国": "ko", + "俄罗斯": "ru", + "法国": "fr", + "德国": "de", + "意大利": "it", + "西班牙": "es", + "墨西哥": "es", + "巴西": "pt", + "葡萄牙": "pt", + "印度": "hi", + "泰国": "th", + "越南": "vi", + "马来西亚": "ms", + "印度尼西亚": "id", + "阿联酋": "ar", + "沙特阿拉伯": "ar", + "埃及": "ar", + "以色列": "he", + "缅甸": "my", + "斯里兰卡": "ta", + "巴基斯坦": "ur", + "孟加拉国": "bn", + "波兰": "pl", + "荷兰": "nl", + "罗马尼亚": "ro", + "土耳其": "tr", + "老挝": "lo", + "乌克兰": "uk", + "芬兰": "fi", + "南非": "af", + "阿尔巴尼亚": "sq", + "安道尔": "ca", + "安提瓜和巴布达": "en", + "阿根廷": "es", + "亚美尼亚": "hy", + "奥地利": "de", + "阿塞拜疆": "az", + "巴哈马": "en", + "巴林": "ar", + "巴巴多斯": "en", + "白俄罗斯": "be", + "比利时": "fr", + "伯利兹": "en", + "贝宁": "fr", + "不丹": "dz", + "玻利维亚": "es", + "波斯尼亚和黑塞哥维那": "bs", + "博茨瓦纳": "en", + "文莱": "ms", + "保加利亚": "bg", + "布基纳法索": "fr", + "布隆迪": "fr", + "柬埔寨": "km", + "喀麦隆": "fr", + "加拿大": "en", + "佛得角": "pt", + "开曼群岛": "en", + "中非共和国": "fr", + "乍得": "fr", + "智利": "es", + "中国": "zh-CN", + "圣诞岛": "en", + "科科斯群岛": "en", + "哥伦比亚": "es", + "科摩罗": "ar", + "刚果": "fr", + "库克群岛": "en", + "哥斯达黎加": "es", + "科特迪瓦": "fr", + "克罗地亚": "hr", + "古巴": "es", + "库拉索": "nl", + "塞浦路斯": "el", + "捷克": "cs", + "丹麦": "da", + "吉布提": "fr", + "多米尼克": "en", + "多米尼加共和国": "es", + "厄瓜多尔": "es", + "萨尔瓦多": "es", + "赤道几内亚": "es", + "厄立特里亚": "ti", + "爱沙尼亚": "et", + "埃斯瓦蒂尼": "en", + "埃塞俄比亚": "am", + "福克兰群岛": "en", + "法罗群岛": "fo", + "斐济": "en", + "法属圭亚那": "fr", + "法属波利尼西亚": "fr", + "法属南部领地": "fr", + "加蓬": "fr", + "冈比亚": "en", + "格鲁吉亚": "ka", + "加纳": "en", + "直布罗陀": "en", + "希腊": "el", + "格陵兰": "kl", + "格林纳达": "en", + "瓜德罗普": "fr", + "关岛": "en", + "危地马拉": "es", + "根西岛": "en", + "几内亚": "fr", + "几内亚比绍": "pt", + "圭亚那": "en", + "海地": "fr", + "赫德岛和麦克唐纳群岛": "en", + "梵蒂冈": "it", + "洪都拉斯": "es", + "中国香港特别行政区": "zh-TW", + "匈牙利": "hu", + "冰岛": "is", + "伊朗": "fa", + "伊拉克": "ar", + "爱尔兰": "en", + "曼岛": "en", + "牙买加": "en", + "泽西岛": "en", + "约旦": "ar", + "哈萨克斯坦": "kk", + "肯尼亚": "en", + "基里巴斯": "en", + "朝鲜": "ko", + "科威特": "ar", + "吉尔吉斯斯坦": "ky", + "拉脱维亚": "lv", + "黎巴嫩": "ar", + "莱索托": "en", + "利比里亚": "en", + "利比亚": "ar", + "列支敦士登": "de", + "立陶宛": "lt", + "卢森堡": "fr", + "中国澳门特别行政区": "zh-TW", + "马达加斯加": "fr", + "马拉维": "en", + "马尔代夫": "dv", + "马里": "fr", + "马耳他": "mt", + "马绍尔群岛": "en", + "马提尼克": "fr", + "毛里塔尼亚": "ar", + "毛里求斯": "en", + "马约特": "fr", + "密克罗尼西亚": "en", + "摩尔多瓦": "ro", + "摩纳哥": "fr", + "蒙古": "mn", + "黑山": "sr", + "蒙特塞拉特": "en", + "摩洛哥": "ar", + "莫桑比克": "pt", + "纳米比亚": "en", + "瑙鲁": "en", + "尼泊尔": "ne", + "新喀里多尼亚": "fr", + "新西兰": "en", + "尼加拉瓜": "es", + "尼日尔": "fr", + "尼日利亚": "en", + "纽埃": "en", + "诺福克岛": "en", + "北马其顿": "mk", + "北马里亚纳群岛": "en", + "挪威": "no", + "阿曼": "ar", + "帕劳": "en", + "巴勒斯坦": "ar", + "巴拿马": "es", + "巴布亚新几内亚": "en", + "巴拉圭": "es", + "秘鲁": "es", + "菲律宾": "tl", + "皮特凯恩群岛": "en", + "波多黎各": "es", + "卡塔尔": "ar", + "留尼汪": "fr", + "卢旺达": "rw", + "圣巴泰勒米": "fr", + "圣赫勒拿": "en", + "圣基茨和尼维斯": "en", + "圣卢西亚": "en", + "法属圣马丁": "fr", + "圣皮埃尔和密克隆": "fr", + "圣文森特和格林纳丁斯": "en", + "萨摩亚": "sm", + "圣马力诺": "it", + "圣多美和普林西比": "pt", + "塞内加尔": "fr", + "塞尔维亚": "sr", + "塞舌尔": "fr", + "塞拉利昂": "en", + "新加坡": "en", + "荷属圣马丁": "nl", + "斯洛伐克": "sk", + "斯洛文尼亚": "sl", + "所罗门群岛": "en", + "索马里": "so", + "南乔治亚岛和南桑威奇群岛": "en", + "南苏丹": "en", + "苏丹": "ar", + "苏里南": "nl", + "斯瓦尔巴群岛和扬马延岛": "no", + "瑞典": "sv", + "瑞士": "de", + "叙利亚": "ar", + "台湾省": "zh-TW", + "塔吉克斯坦": "tg", + "坦桑尼亚": "sw", + "东帝汶": "tet", + "多哥": "fr", + "托克劳": "en", + "汤加": "to", + "特立尼达和多巴哥": "en", + "突尼斯": "ar", + "土库曼斯坦": "tk", + "特克斯和凯科斯群岛": "en", + "图瓦卢": "en", + "乌干达": "en", + "美国本土外小岛屿": "en", + "乌拉圭": "es", + "乌兹别克斯坦": "uz", + "瓦努阿图": "bi", + "委内瑞拉": "es", + "英属维尔京群岛": "en", + "美属维尔京群岛": "en", + "瓦利斯和富图纳": "fr", + "西撒哈拉": "ar", + "也门": "ar", + "赞比亚": "en", + "津巴布韦": "en", + "阿富汗": "fa", + "阿尔及利亚": "ar", + "美属萨摩亚": "en", + "安哥拉": "pt", + "安圭拉": "en", + "南极洲": "en", + "百慕大": "en", + "荷属加勒比区": "nl", + "布韦岛": "no", + "英属印度洋领地": "en", + + } + + @classmethod + def get_language_code(cls, country): + return cls.country_to_language.get(country) + +# 使用示例 +if __name__ == "__main__": + mapper = CountryLanguageMapper() + countries = ['英国', '美国', '日本', '未知国家'] + for country in countries: + code = mapper.get_language_code(country) + if code: + print(f"{country} 对应的语言代码是 {code}") + else: + print(f"没有找到 {country} 对应的语言代码") \ No newline at end of file diff --git a/Utils/DevDiskImageDeployer.py b/Utils/DevDiskImageDeployer.py new file mode 100644 index 0000000..74dcfda --- /dev/null +++ b/Utils/DevDiskImageDeployer.py @@ -0,0 +1,135 @@ +# support_deployer.py +import os +import re +import shutil +from pathlib import Path +from dataclasses import dataclass, field +from typing import Iterable, Optional + +VERSION_RE = re.compile(r"^\d+(?:\.\d+)*$") # 15 / 15.6 / 16.7 / 16.7.1 + +def _find_support_root(hint: Optional[Path]) -> Optional[Path]: + """ + 1) 优先:显式传入的 hint + 2) 其次:环境变量 SUPPORT_DDI_DIR + 3) 再次:从 __file__ 所在目录向上搜索 3 层,找名为 'SupportFiles' 的目录 + """ + if hint and hint.exists(): + return hint.resolve() + + env = os.environ.get("SUPPORT_DDI_DIR") + if env: + p = Path(env).expanduser() + if p.exists(): + return p.resolve() + + here = Path(__file__).resolve().parent + for _ in range(4): # 当前目录 + 向上 3 层 + cand = here / "SupportFiles" + if cand.exists(): + return cand.resolve() + here = here.parent + return None + + +@dataclass +class DevDiskImageDeployer: + """ + 同步 SupportFiles// 或 SupportFiles/.zip 到 ~/.tidevice/device-support + - 目录:复制为 ~/.tidevice/device-support// + - zip:原样复制为 ~/.tidevice/device-support/.zip (不解压) + - 已存在则跳过;如设置 overwrite=True 则覆盖 + """ + project_support_root: Optional[Path] = None + cache_root: Optional[Path] = None + verbose: bool = True + dry_run: bool = False + overwrite: bool = False + + _src_dir: Path = field(init=False, repr=False) + _cache_dir: Path = field(init=False, repr=False) + + def __post_init__(self): + src = _find_support_root(self.project_support_root) + if src is None: + raise FileNotFoundError( + "未找到 SupportFiles 目录。" + "可传入 project_support_root,或设置环境变量 SUPPORT_DDI_DIR," + "或确保在当前文件上层 3 级目录内存在名为 'SupportFiles' 的目录。" + ) + self._src_dir = src + + if self.cache_root is None: + self._cache_dir = Path.home() / ".tidevice" / "device-support" + else: + self._cache_dir = Path(self.cache_root).expanduser().resolve() + self._cache_dir.mkdir(parents=True, exist_ok=True) + + if self.verbose: + print(f"[INFO] resolved SupportFiles = {self._src_dir}") + print(f"[INFO] cache_dir = {self._cache_dir}") + + parent = self._src_dir.parent + try: + siblings = ", ".join(sorted(p.name for p in parent.iterdir() if p.is_dir())) + print(f"[INFO] SupportFiles parent = {parent}") + print(f"[INFO] siblings = {siblings}") + except Exception: + pass + + def deploy_all(self): + entries = list(self._iter_version_entries(self._src_dir)) + copied = skipped = 0 + + for p, kind, version in entries: + # kind: "dir" 或 "zip" + if kind == "dir": + dst = self._cache_dir / version + exists = dst.exists() + if exists and not self.overwrite: + skipped += 1 + # if self.verbose: + # print(f"[SKIP] {dst} 已存在(目录)") + continue + if exists and self.overwrite and not self.dry_run: + shutil.rmtree(dst) + + if self.verbose: + print(f"[COPY] DIR {p} -> {dst}") + if not self.dry_run: + shutil.copytree(p, dst) + copied += 1 + + elif kind == "zip": + dst = self._cache_dir / f"{version}.zip" + exists = dst.exists() + if exists and not self.overwrite: + skipped += 1 + # if self.verbose: + # print(f"[SKIP] {dst} 已存在(zip)") + continue + if exists and self.overwrite and not self.dry_run: + dst.unlink() + + if self.verbose: + print(f"[COPY] ZIP {p} -> {dst}") + if not self.dry_run: + # 用 copy2 保留 mtime 等元数据 + shutil.copy2(p, dst) + copied += 1 + + if self.verbose: + print(f"[SUMMARY] copied={copied}, skipped={skipped}, total={copied+skipped}") + + # -------- helpers -------- + def _iter_version_entries(self, root: Path) -> Iterable[tuple[Path, str, str]]: + """ + 迭代返回 (路径, 类型, 版本号) + - 目录:名称需匹配版本号 + - zip:stem(去除后缀)的名称需匹配版本号 + """ + for p in sorted(root.iterdir()): + if p.is_dir() and VERSION_RE.match(p.name): + yield (p, "dir", p.name) + elif p.is_file() and p.suffix.lower() == ".zip" and VERSION_RE.match(p.stem): + yield (p, "zip", p.stem) diff --git a/Utils/IOSAIStorage.py b/Utils/IOSAIStorage.py new file mode 100644 index 0000000..c9fd6b7 --- /dev/null +++ b/Utils/IOSAIStorage.py @@ -0,0 +1,88 @@ +import json +import os +from pathlib import Path + + +class IOSAIStorage: + + @staticmethod + def _get_iosai_dir() -> Path: + """获取 C:/Users/<用户名>/IOSAI/ 目录""" + user_dir = Path.home() + iosai_dir = user_dir / "IOSAI" + iosai_dir.mkdir(parents=True, exist_ok=True) + return iosai_dir + + + @classmethod + def save(cls, data: dict | list, filename: str = "data.json", mode: str = "overwrite") -> Path: + file_path = cls._get_iosai_dir() / filename + file_path.parent.mkdir(parents=True, exist_ok=True) + + def _load_json(): + try: + return json.loads(file_path.read_text("utf-8")) + except Exception: + return {} if isinstance(data, dict) else [] + + if mode == "merge" and isinstance(data, dict): + old = _load_json() + if not isinstance(old, dict): + old = {} + old.update(data) + to_write = old + elif mode == "append" and isinstance(data, list): + old = _load_json() + if not isinstance(old, list): + old = [] + old.extend(data) + to_write = old + else: + to_write = data # 覆盖 + + # 原子写入 + tmp = file_path.with_suffix(file_path.suffix + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(to_write, f, ensure_ascii=False, indent=2) + os.replace(tmp, file_path) + print(f"[IOSAIStorage] 已写入: {file_path}") + return file_path + + @classmethod + def load(cls, filename: str = "data.json") -> dict | list | None: + """ + 从 C:/Users/<用户名>/IOSAI/filename 读取数据 + """ + file_path = cls._get_iosai_dir() / filename + if not file_path.exists(): + print(f"[IOSAIStorage] 文件不存在: {file_path}") + return {} + + try: + with open(file_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print(f"[IOSAIStorage] 读取失败: {e}") + return {} + + + @classmethod + def overwrite(cls, data: dict | list, filename: str = "data.json") -> Path: + """ + 强制覆盖写入数据到 C:/Users/<用户名>/IOSAI/filename + (无论是否存在,都会写入) + """ + file_path = cls._get_iosai_dir() / filename + try: + # "w" 模式本身就是覆盖,但这里单独做一个方法 + with open(file_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + print(f"[IOSAIStorage] 已覆盖写入: {file_path}") + except Exception as e: + print(f"[IOSAIStorage] 覆盖失败: {e}") + return file_path + + + + + diff --git a/Utils/JsonUtils.py b/Utils/JsonUtils.py new file mode 100644 index 0000000..b1917a6 --- /dev/null +++ b/Utils/JsonUtils.py @@ -0,0 +1,262 @@ +import json +import os +from pathlib import Path + +import portalocker as locker # ① 引入跨平台锁 + + +class JsonUtils: + @staticmethod + def _normalize_filename(filename: str) -> str: + """ + 确保文件名以 .json 结尾 + """ + if not filename.endswith(".json"): + filename = f"{filename}.json" + return filename + + @staticmethod + def _get_data_path(filename: str) -> str: + """ + 根据文件名生成 data 目录下的完整路径 + """ + filename = JsonUtils._normalize_filename(filename) + base_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) # 当前项目根目录 + data_dir = os.path.join(base_dir, "data") + Path(data_dir).mkdir(parents=True, exist_ok=True) # 确保 data 目录存在 + return os.path.join(data_dir, filename) + + @staticmethod + def read_json(filename: str) -> dict: + """ + 读取 JSON 文件,返回字典 + 如果文件不存在,返回空字典 + """ + file_path = JsonUtils._get_data_path(filename) + try: + if not os.path.exists(file_path): + return {} + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception as e: + print(f"读取 JSON 文件失败: {e}") + return {} + + @staticmethod + def write_json(filename: str, data: dict, overwrite: bool = True) -> bool: + """ + 将字典写入 JSON 文件 + :param filename: 文件名(不用写后缀,自动补 .json) + :param data: 要写入的字典 + :param overwrite: True=覆盖写,False=合并更新 + """ + file_path = JsonUtils._get_data_path(filename) + try: + if not overwrite and os.path.exists(file_path): + with open(file_path, "r", encoding="utf-8") as f: + old_data = json.load(f) + if not isinstance(old_data, dict): + old_data = {} + old_data.update(data) + data = old_data + + with open(file_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=4) + return True + except Exception as e: + print(f"写入 JSON 文件失败: {e}") + return False + + @staticmethod + def update_json(filename: str, new_data: dict) -> bool: + """ + 修改 JSON 文件: + - 如果 key 已存在,则修改其值 + - 如果 key 不存在,则新增 + """ + try: + data = JsonUtils.read_json(filename) + if not isinstance(data, dict): + data = {} + data.update(new_data) + return JsonUtils.write_json(filename, data) + except Exception as e: + print(f"更新 JSON 文件失败: {e}") + return False + + @staticmethod + def delete_json_key(filename: str, key: str) -> bool: + """ + 删除 JSON 文件中的某个 key + """ + try: + data = JsonUtils.read_json(filename) + if not isinstance(data, dict): + data = {} + if key in data: + del data[key] + return JsonUtils.write_json(filename, data) + except Exception as e: + print(f"删除 JSON key 失败: {e}") + return False + # "-------------------------------------------------" + @classmethod + def _read_json_list(cls, file_path: Path) -> list: + try: + if not file_path.exists(): + return [] + with file_path.open("r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, list) else [] + except Exception: + return [] + + @classmethod + def _write_json_list(cls, file_path: Path, data: list) -> None: + file_path.parent.mkdir(parents=True, exist_ok=True) + with file_path.open("w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=4) + + # --- 新增:通用追加(不做字段校验) --- + # @classmethod + # def append_json_items(cls, items, filename="log/last_message.json"): + # """ + # 将 dict 或 [dict, ...] 追加到 JSON 文件(数组)中;不校验字段。 + # """ + # file_path = Path(filename) + # data = cls._read_json_list(file_path) + # + # # 统一成 list + # if isinstance(items, dict): + # items = [items] + # elif not isinstance(items, list): + # # 既不是 dict 也不是 list,直接忽略 + # return + # + # # 只接受字典项 + # items = [it for it in items if isinstance(it, dict)] + # if not items: + # return + # + # data.extend(items) + # + # # LogManager.method_info(filename,"路径") + # cls._write_json_list(file_path, data) + + @classmethod + def append_json_items(cls, items, filename="log/last_message.json"): + file_path = Path(filename) + data = cls._read_json_list(file_path) + + # 统一成 list + if isinstance(items, dict): + items = [items] + elif not isinstance(items, list): + return + + # 只保留 sender 非空的字典 + items = [ + it for it in items + if isinstance(it, dict) and it.get("sender") != "" + ] + if not items: + return + + data.extend(items) + cls._write_json_list(file_path, data) + + @classmethod + def update_json_items(cls, match: dict, patch: dict, filename="log/last_message.json", multi: bool = True) -> int: + """ + 修改 JSON 文件(数组)中符合条件的项 + :param match: 匹配条件(如 {"sender": "xxx"}) + :param patch: 要修改/更新的字段(如 {"status": 1}) + :param filename: JSON 文件路径 + :param multi: True=修改所有匹配项,False=只修改第一项 + :return: 修改的条数 + """ + file_path = Path(filename) + data = cls._read_json_list(file_path) + + if not isinstance(match, dict) or not isinstance(patch, dict): + return 0 + + updated = 0 + for idx, item in enumerate(data): + if not isinstance(item, dict): + continue + + # 判断是否匹配 + if all(item.get(k) == v for k, v in match.items()): + data[idx].update(patch) + updated += 1 + if not multi: + break + + if updated > 0: + cls._write_json_list(file_path, data) + + return updated + + + + + @classmethod + def query_all_json_items(cls, filename="log/last_message.json"): + """ + 读取 JSON 数组文件,过滤掉 sender 或 text 为空的记录 + :param filename: 文件路径 + :return: 有效记录列表,可能为空 + """ + file_path = Path(filename) + data = cls._read_json_list(file_path) + if not isinstance(data, list): + return [] + + def _is_valid(d): + if not isinstance(d, dict): + return False + sender = d.get("sender") or "" + text = d.get("text") or "" + return ( + isinstance(sender, str) + and isinstance(text, str) + and sender.strip() != "" + and text.strip() != "" + ) + + return [item for item in data if _is_valid(item)] + + + + @classmethod + def delete_json_items(cls, + match: dict, + filename: str = "log/last_message.json", + multi: bool = True) -> int: + file_path = Path(filename) + with file_path.open('r+', encoding='utf-8') as f: + locker.lock(f, locker.LOCK_EX) # ② 加独占锁(Windows/Linux 通用) + try: + data = json.load(f) + if not isinstance(match, dict): + return 0 + + deleted = 0 + new_data = [] + for item in data: + if isinstance(item, dict) and all(item.get(k) == v for k, v in match.items()): + if multi or deleted == 0: # 删多条 / 第一条 + deleted += 1 + continue + new_data.append(item) + + if deleted: + f.seek(0) + json.dump(new_data, f, ensure_ascii=False, indent=2) + f.truncate() + + return deleted + finally: + locker.unlock(f) # ③ 解锁 \ No newline at end of file diff --git a/Utils/LogManager.py b/Utils/LogManager.py new file mode 100644 index 0000000..9a81fe5 --- /dev/null +++ b/Utils/LogManager.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +import datetime +import io +import logging +import os +import re +import sys +import shutil +import zipfile +from pathlib import Path +import requests + +# ========= 全局:强制 UTF-8(打包 EXE / 无控制台也生效) ========= +def _force_utf8_everywhere(): + os.environ.setdefault("PYTHONUTF8", "1") + os.environ.setdefault("PYTHONIOENCODING", "utf-8") + # windowed 模式下 stdout/stderr 可能没有 buffer,这里做保护包装 + try: + if getattr(sys.stdout, "buffer", None): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + except Exception: + pass + try: + if getattr(sys.stderr, "buffer", None): + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + except Exception: + pass + +_force_utf8_everywhere() + +class LogManager: + """ + 设备级与“设备+方法”级日志管理: + - log//info.log | warning.log | error.log + - log//.log + - 文件统一 UTF-8 编码,避免 GBK/CP936 导致的 emoji 报错 + - 提供 clearLogs() 与 upload_all_logs() + """ + # 运行根目录:打包后取 exe 目录;源码运行取项目目录 + if getattr(sys, "frozen", False): + projectRoot = os.path.dirname(sys.executable) + else: + projectRoot = os.path.dirname(os.path.dirname(__file__)) + + logDir = os.path.join(projectRoot, "log") + _method_loggers = {} # 缓存“设备+方法”的 logger + + # ---------- 工具:安全文本/文件名 ---------- + + @staticmethod + def _safe_text(obj) -> str: + """把任意对象安全转为可写字符串(避免因编码问题再次抛异常)""" + try: + if isinstance(obj, bytes): + return obj.decode("utf-8", "replace") + s = str(obj) + # 确保解码上屏不再出错 + _ = s.encode("utf-8", "replace") + return s + except Exception: + try: + return repr(obj) + except Exception: + return "" + + @classmethod + def _safe_filename(cls, name: str, max_len: int = 80) -> str: + """ + 将方法名/udid等转成安全文件名: + - 允许字母数字、点、下划线、连字符 + - 保留常见 CJK 字符(中日韩) + - 其余替换为下划线;合并下划线;避免保留名;限长 + """ + if not name: + return "unknown" + name = str(name).strip() + name = re.sub(r'[\\/:*?"<>|\r\n\t]+', '_', name) # Windows 非法字符 + name = re.sub( + r'[^a-zA-Z0-9_.\-' + r'\u4e00-\u9fff' # 中 + r'\u3040-\u30ff' # 日 + r'\uac00-\ud7a3' # 韩 + r']+', '_', name + ) + name = re.sub(r'_+', '_', name).strip(' _.') + name = name or "unknown" + if re.fullmatch(r'(?i)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])', name): + name = f"_{name}" + return name[:max_len] or "unknown" + + # ---------- 设备级固定文件:info/warning/error ---------- + + @classmethod + def _setupLogger(cls, udid, name, logName, level=logging.INFO): + """创建或获取 logger,并绑定到设备目录下的固定文件(info.log / warning.log / error.log)""" + udid_key = cls._safe_filename(udid or "system") + deviceLogDir = os.path.join(cls.logDir, udid_key) + os.makedirs(deviceLogDir, exist_ok=True) + logFile = os.path.join(deviceLogDir, logName) + + logger_name = f"{udid_key}_{name}" + logger = logging.getLogger(logger_name) + logger.setLevel(level) + logger.propagate = False # 不向根 logger 传播,避免重复 + + # 避免重复添加同一路径的 file handler + abs_target = os.path.abspath(logFile) + for h in logger.handlers: + if isinstance(h, logging.FileHandler) and getattr(h, "baseFilename", "") == abs_target: + return logger + + fileHandler = logging.FileHandler(logFile, mode="a", encoding="utf-8") + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + fileHandler.setFormatter(formatter) + logger.addHandler(fileHandler) + return logger + + @classmethod + def info(cls, text, udid="system"): + msg = cls._safe_text(f"[{udid}] {text}") + cls._setupLogger(udid, "infoLogger", "info.log", level=logging.INFO).info(msg) + + @classmethod + def warning(cls, text, udid="system"): + msg = cls._safe_text(f"[{udid}] {text}") + cls._setupLogger(udid, "warningLogger", "warning.log", level=logging.WARNING).warning(msg) + + @classmethod + def error(cls, text, udid="system"): + msg = cls._safe_text(f"[{udid}] {text}") + cls._setupLogger(udid, "errorLogger", "error.log", level=logging.ERROR).error(msg) + + # ---------- “设备+方法”独立文件:/.log ---------- + + @classmethod + def _setupMethodLogger(cls, udid: str, method: str, level=logging.INFO): + """ + 为某设备的某个方法单独创建 logger:log//.log + """ + udid_key = cls._safe_filename(udid or "system") + method_key = cls._safe_filename(method or "general") + cache_key = (udid_key, method_key) + + # 命中缓存 + logger = cls._method_loggers.get(cache_key) + if logger: + return logger + + deviceLogDir = os.path.join(cls.logDir, udid_key) + os.makedirs(deviceLogDir, exist_ok=True) + logFile = os.path.join(deviceLogDir, f"{method_key}.log") + + logger_name = f"{udid_key}.{method_key}" + logger = logging.getLogger(logger_name) + logger.setLevel(level) + logger.propagate = False + + abs_target = os.path.abspath(logFile) + for h in logger.handlers: + if isinstance(h, logging.FileHandler) and getattr(h, "baseFilename", "") == abs_target: + cls._method_loggers[cache_key] = logger + return logger + + fileHandler = logging.FileHandler(logFile, mode="a", encoding="utf-8") + formatter = logging.Formatter( + "%(asctime)s - %(levelname)s - %(name)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + fileHandler.setFormatter(formatter) + logger.addHandler(fileHandler) + + cls._method_loggers[cache_key] = logger + return logger + + @classmethod + def method_info(cls, text, method, udid="system"): + msg = cls._safe_text(f"[{udid}][{method}] {text}") + cls._setupMethodLogger(udid, method, level=logging.INFO).info(msg) + + @classmethod + def method_warning(cls, text, method, udid="system"): + msg = cls._safe_text(f"[{udid}][{method}] {text}") + cls._setupMethodLogger(udid, method, level=logging.WARNING).warning(msg) + + @classmethod + def method_error(cls, text, method, udid="system"): + msg = cls._safe_text(f"[{udid}][{method}] {text}") + cls._setupMethodLogger(udid, method, level=logging.ERROR).error(msg) + + # ---------- 清空日志 ---------- + + @classmethod + def clearLogs(cls): + print("清空日志") + """启动时清空 log 目录下所有文件""" + # 先关闭所有 logger 的文件句柄 + for _, logger in logging.Logger.manager.loggerDict.items(): + if isinstance(logger, logging.Logger): + for handler in list(logger.handlers): + try: + handler.close() + except Exception: + pass + logger.removeHandler(handler) + + log_path = Path(cls.logDir) + if log_path.exists(): + for item in log_path.iterdir(): + try: + if item.is_file(): + item.unlink(missing_ok=True) # py>=3.8 + elif item.is_dir(): + shutil.rmtree(item, ignore_errors=True) + except Exception: + # 不阻塞清理 + pass + + cls._method_loggers.clear() + + # ---------- 上传所有日志(内存打包 zip) ---------- + + @classmethod + def upload_all_logs(cls, server_url, token, userId, tenantId): + """ + 将 log/ 目录下所有日志打包为 zip(内存),上传至服务器: + - headers: {"vvtoken": } + - form: {"tenantId": , "userId": , "file": } + 返回 True/False + """ + try: + log_path = Path(cls.logDir) + if not log_path.exists(): + logging.info("[upload_all_logs] 日志目录不存在:%s", log_path) + return False + + # 文件名仅用于表单,不落盘,可包含安全字符 + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{timestamp}_logs.zip" + logging.info("[upload_all_logs] 打包文件名:%s", filename) + + # 1) 内存中打包 zip + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for p in log_path.rglob("*"): + if p.is_file(): + arcname = str(p.relative_to(log_path)) + zf.write(p, arcname=arcname) + zip_bytes = zip_buf.getvalue() + + # 2) 组织请求 + headers = {"vvtoken": token} if token else {} + data = {"tenantId": tenantId, "userId": userId} + files = {"file": (filename, io.BytesIO(zip_bytes), "application/zip")} + + # 3) 上传 + resp = requests.post(server_url, headers=headers, data=data, files=files, timeout=120) + ok = False + try: + js = resp.json() + ok = bool(js.get("data")) + except Exception: + # 响应不是 JSON,也许是纯文本;降级按状态码判断 + ok = resp.ok + + if ok: + logging.info("[upload_all_logs] 上传成功:%s", server_url) + return True + else: + logging.error("[upload_all_logs] 上传失败,status=%s, text=%s", resp.status_code, LogManager._safe_text(resp.text)) + return False + + except Exception as e: + logging.error("[upload_all_logs] 异常:%s", LogManager._safe_text(e)) + return False diff --git a/Utils/OCRUtils.py b/Utils/OCRUtils.py new file mode 100644 index 0000000..2bd9ee2 --- /dev/null +++ b/Utils/OCRUtils.py @@ -0,0 +1,243 @@ +import os +import cv2 +import numpy as np +from typing import List, Tuple, Union, Optional +from PIL import Image + +ArrayLikeImage = Union[np.ndarray, str, Image.Image] + +class OCRUtils: + @classmethod + def _to_gray(cls, img: ArrayLikeImage) -> np.ndarray: + """ + 接受路径/np.ndarray/PIL.Image,统一转为灰度 np.ndarray。 + """ + # 路径 + if isinstance(img, str): + arr = cv2.imread(img, cv2.IMREAD_GRAYSCALE) + if arr is None: + raise FileNotFoundError(f"图像加载失败,请检查路径: {img}") + return arr + + # PIL.Image + if isinstance(img, Image.Image): + return cv2.cvtColor(np.array(img.convert("RGB")), cv2.COLOR_RGB2GRAY) + + # numpy 数组 + if isinstance(img, np.ndarray): + if img.ndim == 2: + return img # 已是灰度 + if img.ndim == 3: + return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + raise ValueError("不支持的图像维度(期望 2D 灰度或 3D BGR/RGB)") + + raise TypeError("large_image 类型必须是 str / np.ndarray / PIL.Image.Image") + + @classmethod + def non_max_suppression( + cls, + boxes: List[List[float]], + scores: Optional[np.ndarray] = None, + overlapThresh: float = 0.5 + ) -> np.ndarray: + """ + boxes: [ [x1,y1,x2,y2], ... ] + scores: 每个框的置信度(用于“按分数做 NMS”)。若为 None,则退化为按 y2 排序的经典近似。 + 返回: 经过 NMS 保留的 boxes(int) ndarray,形状 (N,4) + """ + if len(boxes) == 0: + return np.empty((0, 4), dtype=int) + + boxes = np.asarray(boxes, dtype=np.float32) + x1, y1, x2, y2 = boxes.T + areas = (x2 - x1 + 1) * (y2 - y1 + 1) + + if scores is None: + order = np.argsort(y2) # 经典写法 + else: + scores = np.asarray(scores, dtype=np.float32) + order = np.argsort(scores)[::-1] # 分数从高到低 + + keep = [] + while order.size > 0: + i = order[0] if scores is not None else order[-1] + keep.append(i) + + rest = order[1:] if scores is not None else order[:-1] + + xx1 = np.maximum(x1[i], x1[rest]) + yy1 = np.maximum(y1[i], y1[rest]) + xx2 = np.minimum(x2[i], x2[rest]) + yy2 = np.minimum(y2[i], y2[rest]) + + w = np.maximum(0, xx2 - xx1 + 1) + h = np.maximum(0, yy2 - yy1 + 1) + inter = w * h + ovr = inter / areas[rest] + + inds = np.where(ovr <= overlapThresh)[0] + order = rest[inds] + + return boxes[keep].astype(int) + + # @classmethod + # def find_template( + # cls, + # template_path: str, + # large_image: ArrayLikeImage, + # threshold: float = 0.8, + # overlapThresh: float = 0.5, + # return_boxes: bool = False + # ) -> Union[List[Tuple[int, int]], Tuple[List[Tuple[int, int]], np.ndarray]]: + # """ + # 在 large_image 中查找 template_path 模板的位置。 + # - large_image 可为文件路径、np.ndarray 或 PIL.Image + # - threshold: 模板匹配阈值(TM_CCOEFF_NORMED) + # - overlapThresh: NMS 重叠阈值 + # - return_boxes: True 时同时返回保留的框数组 (N,4) + # + # 返回: + # centers 或 (centers, boxes) + # centers: [(cx, cy), ...] + # boxes: [[x1,y1,x2,y2], ...] (np.ndarray, int) + # """ + # # 模板(灰度) + # template = cv2.imread(template_path, cv2.IMREAD_GRAYSCALE) + # if template is None: + # raise FileNotFoundError(f"模板图像加载失败,请检查路径: {template_path}") + # + # # 大图(灰度) + # gray = cls._to_gray(large_image) + # + # # 模板尺寸 + # tw, th = template.shape[::-1] + # + # # 模板匹配(相关系数归一化) + # result = cv2.matchTemplate(gray, template, cv2.TM_CCOEFF_NORMED) + # + # # 阈值筛选 + # ys, xs = np.where(result >= threshold) + # if len(xs) == 0: + # return ([], np.empty((0, 4), dtype=int)) if return_boxes else [] + # + # # 收集候选框与分数 + # boxes = [] + # scores = [] + # for (x, y) in zip(xs, ys): + # boxes.append([x, y, x + tw, y + th]) + # scores.append(result[y, x]) + # + # # 按分数做 NMS + # boxes_nms = cls.non_max_suppression(boxes, scores=np.array(scores), overlapThresh=overlapThresh) + # + # # 计算中心点 + # centers = [((x1 + x2) // 2, (y1 + y2) // 2) for (x1, y1, x2, y2) in boxes_nms] + # + # + # + # if return_boxes: + # return centers, boxes_nms + # + # + # return centers + + @classmethod + def find_template( + cls, + template_path: str, + large_image: ArrayLikeImage, + threshold: float = 0.8, + overlapThresh: float = 0.5, + return_boxes: bool = False + ) -> Union[List[Tuple[int, int]], Tuple[List[Tuple[int, int]], np.ndarray]]: + """ + 在 large_image 中查找 template_path 模板的位置。 + - large_image 可为文件路径、np.ndarray 或 PIL.Image + - threshold: 模板匹配阈值(TM_CCOEFF_NORMED) + - overlapThresh: NMS 重叠阈值 + - return_boxes: True 时同时返回保留的框数组 (N,4) + + 若检测结果为空,则在相同阈值下最多重试三次(共 3 次尝试)。 + 返回: + centers 或 (centers, boxes) + centers: [(cx, cy), ...] + boxes: [[x1,y1,x2,y2], ...] (np.ndarray, int) + """ + + if not os.path.isfile(template_path): + print(f"模板文件不存在 → {template_path}") + raise FileNotFoundError(f"模板文件不存在 → {template_path}") + + size = os.path.getsize(template_path) + if size == 0: + print(f"模板文件大小为 0 → {template_path} ") + raise ValueError(f"模板文件大小为 0 → {template_path}") + # 模板(灰度) + template = cv2.imread(template_path, cv2.IMREAD_GRAYSCALE) + if template is None: + raise FileNotFoundError(f"模板图像加载失败,请检查路径: {template_path}") + + # 大图(灰度) + gray = cls._to_gray(large_image) + + # 模板尺寸 + tw, th = template.shape[::-1] + + # 内部:执行一次匹配并返回 (centers, boxes_nms) + def _match_once(cur_threshold: float): + # 模板匹配(相关系数归一化) + result = cv2.matchTemplate(gray, template, cv2.TM_CCOEFF_NORMED) + + # 阈值筛选 + ys, xs = np.where(result >= cur_threshold) + if len(xs) == 0: + return [], np.empty((0, 4), dtype=int) + + # 收集候选框与分数 + boxes = [] + scores = [] + for (x, y) in zip(xs, ys): + boxes.append([int(x), int(y), int(x + tw), int(y + th)]) + scores.append(float(result[y, x])) + + # 按分数做 NMS + boxes_nms = cls.non_max_suppression( + boxes, + scores=np.asarray(scores, dtype=np.float32), + overlapThresh=overlapThresh + ) + + # 计算中心点(转为 Python int) + centers = [(int((x1 + x2) // 2), int((y1 + y2) // 2)) + for (x1, y1, x2, y2) in boxes_nms] + + # 统一为 np.ndarray[int] + boxes_nms = np.asarray(boxes_nms, dtype=int) + return centers, boxes_nms + + # ===== 重试控制(最多 3 次)===== + MAX_RETRIES = 3 + THRESHOLD_DECAY = 0.0 # 如需越试越宽松,可改为 0.02~0.05;不需要则保持 0 + MIN_THRESHOLD = 0.6 + + cur_threshold = float(threshold) + last_centers, last_boxes = [], np.empty((0, 4), dtype=int) + + for attempt in range(MAX_RETRIES): + centers, boxes_nms = _match_once(cur_threshold) + if centers: + if return_boxes: + return centers, boxes_nms + return centers + + # 记录最后一次(若最终失败按规范返回空) + last_centers, last_boxes = centers, boxes_nms + + # 为下一次尝试准备(这里默认不衰减阈值;如需可打开 THRESHOLD_DECAY) + if attempt < MAX_RETRIES - 1 and THRESHOLD_DECAY > 0.0: + cur_threshold = max(MIN_THRESHOLD, cur_threshold - THRESHOLD_DECAY) + + # 全部尝试失败 + if return_boxes: + return last_centers, last_boxes + return last_centers \ No newline at end of file diff --git a/Utils/Requester.py b/Utils/Requester.py new file mode 100644 index 0000000..133db19 --- /dev/null +++ b/Utils/Requester.py @@ -0,0 +1,147 @@ +import requests +from Entity.Variables import prologueList, API_KEY +from Utils.IOSAIStorage import IOSAIStorage +from Utils.LogManager import LogManager + +BaseUrl = "https://crawlclient.api.yolozs.com/api/common/" + + +class Requester(): + comment = "comment" + prologue = "prologue" + + @classmethod + def requestPrologue(cls, token): + try: + headers = { + "vvtoken": token, + } + url = BaseUrl + cls.prologue + result = requests.get(headers=headers, url=url, verify=False) + json = result.json() + data = json.get("data") + for i in data: + prologueList.append(i) + except Exception as e: + LogManager.method_error(f"获取requestPrologue失败,报错的原因:{e}", "获取requestPrologue异常") + + # 翻译 + @classmethod + def translation(cls, msg, country="英国"): + try: + if country == "": + country = "英国" + + param = { + "msg": msg, + "country": country, + } + url = "https://ai.yolozs.com/translation" + result = requests.post(url=url, json=param, verify=False) + + LogManager.info(f"翻译 请求的参数:{param}", "翻译") + LogManager.info(f"翻译,状态码:{result.status_code},服务器返回的内容:{result.text}", "翻译") + + if result.status_code != 200: + LogManager.error(f"翻译失败,状态码:{result.status_code},服务器返回的内容:{result.text}") + return None + + json = result.json() + data = json.get("data") + return data + except Exception as e: + LogManager.method_error(f"翻译失败,报错的原因:{e}", "翻译失败异常") + + + + + # 翻译 + @classmethod + def translationToChinese(cls, msg): + try: + param = { + "msg": msg, + } + url = "https://ai.yolozs.com/translationToChinese" + result = requests.post(url=url, json=param, verify=False) + + LogManager.info(f"翻译 请求的参数:{param}", "翻译") + LogManager.info(f"翻译,状态码:{result.status_code},服务器返回的内容:{result.text}", "翻译") + + if result.status_code != 200: + LogManager.error(f"翻译失败,状态码:{result.status_code},服务器返回的内容:{result.text}") + return None + + json = result.json() + data = json.get("data") + return data + except Exception as e: + LogManager.method_error(f"翻译失败,报错的原因:{e}", "翻译失败异常") + + + + # ai聊天 + @classmethod + def chatToAi(cls, param): + + + + + # aiConfig = JsonUtils.read_json("aiConfig") + aiConfig = IOSAIStorage.load("aiConfig.json") + + + + + agentName = aiConfig.get("agentName") + guildName = aiConfig.get("guildName") + contactTool = aiConfig.get("contactTool", "") + contact = aiConfig.get("contact", "") + + age = aiConfig.get("age", 20) + sex = aiConfig.get("sex", "女") + height = aiConfig.get("height", 160) + weight = aiConfig.get("weight", 55) + body_features = aiConfig.get("body_features", "") + nationality = aiConfig.get("nationality", "中国") + personality = aiConfig.get("personality", "") + strengths = aiConfig.get("strengths", "") + + + + + inputs = { + "name": agentName, + "Trade_union": guildName, + "contcat_method": contactTool, + "contcat_info": contact, + "age": age, + "sex": sex, + "height": height, + "weight": weight, + "body_features": body_features, + "nationality": nationality, + "personality": personality, + "strengths": strengths, + } + + param["inputs"] = inputs + + try: + + # url = "https://ai.yolozs.com/chat" + url = "https://ai.yolozs.com/customchat" + + result = requests.post(url=url, json=param, verify=False) + + LogManager.method_info(f"ai聊天的参数:{param}", "ai聊天") + print(f"ai聊天的参数:{param}") + + json = result.json() + data = json.get("answer", "") + session_id = json.get("conversation_id", "") + LogManager.method_info(f"ai聊天返回的内容:{result.json()}", "ai聊天") + + return data, session_id + except Exception as e: + LogManager.method_error(f"ai聊天失败,ai聊天出现异常,报错的原因:{e}", "ai聊天接口异常") diff --git a/Utils/SubprocessKit.py b/Utils/SubprocessKit.py new file mode 100644 index 0000000..40b1d98 --- /dev/null +++ b/Utils/SubprocessKit.py @@ -0,0 +1,24 @@ +import os +import subprocess + +__all__ = ['check_output', 'popen', 'PIPE'] + +# 模块级单例,导入时只创建一次 +if os.name == "nt": + _si = subprocess.STARTUPINFO() + _si.dwFlags |= subprocess.STARTF_USESHOWWINDOW + _si.wShowWindow = subprocess.SW_HIDE +else: + _si = None + +PIPE = subprocess.PIPE + +def check_output(cmd, **kw): + if os.name == "nt": + kw.setdefault('startupinfo', _si) + return subprocess.check_output(cmd, **kw) + +def popen(*args, **kw): + if os.name == "nt": + kw.setdefault('startupinfo', _si) + return subprocess.Popen(*args, **kw) \ No newline at end of file diff --git a/Utils/TencentOCRUtils.py b/Utils/TencentOCRUtils.py new file mode 100644 index 0000000..e8eed22 --- /dev/null +++ b/Utils/TencentOCRUtils.py @@ -0,0 +1,327 @@ +# -*- coding: utf-8 -*- +import base64 +import hashlib +import hmac +import json +import os +import re +import socket +import time +from datetime import datetime, timezone +from http.client import HTTPSConnection +from typing import Any, Dict, List, Optional + +Point = Dict[str, int] +ItemPolygon = Dict[str, int] + + +class TencentOCR: + """腾讯云 OCR 封装,自动从环境变量或配置文件加载密钥""" + + @staticmethod + def _load_secret() -> Dict[str, str]: + # 优先从环境变量读取 + sid = "AKIDXw86q6D8pJYZOEvOm25wZy96oIZcQ1OX" + skey = "ye7MNAj4ub5PVO2TmriLkwtc8QTItGPO" + + # 如果没有,就尝试从 ~/.tencent_ocr.json 加载 + if not sid or not skey: + cfg_path = os.path.expanduser("~/.tencent_ocr.json") + if os.path.exists(cfg_path): + with open(cfg_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + sid = sid or cfg.get("secret_id") + skey = skey or cfg.get("secret_key") + + if not sid or not skey: + raise RuntimeError( + "❌ 未找到腾讯云 OCR 密钥,请设置环境变量 TENCENT_SECRET_ID / TENCENT_SECRET_KEY," + "或在用户目录下创建 ~/.tencent_ocr.json(格式:{\"secret_id\":\"...\",\"secret_key\":\"...\"})" + ) + + return {"secret_id": sid, "secret_key": skey} + + @staticmethod + def _hmac_sha256(key: bytes, msg: str) -> bytes: + return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest() + + @staticmethod + def _strip_data_uri_prefix(b64: str) -> str: + if "," in b64 and b64.strip().lower().startswith("data:"): + return b64.split(",", 1)[1] + return b64 + + @staticmethod + def _now_ts_and_date(): + ts = int(time.time()) + date = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d") + return ts, date + + @staticmethod + def recognize( + *, + image_path: Optional[str] = None, + image_bytes: Optional[bytes] = None, + image_url: Optional[str] = None, + region: Optional[str] = None, + token: Optional[str] = None, + action: str = "GeneralBasicOCR", + version: str = "2018-11-19", + service: str = "ocr", + host: str = "ocr.tencentcloudapi.com", + timeout: int = 15, + ) -> Dict[str, Any]: + """ + 调用腾讯云 OCR,三选一:image_path / image_bytes / image_url + 自动加载密钥(优先环境变量 -> ~/.tencent_ocr.json) + """ + # 读取密钥 + sec = TencentOCR._load_secret() + secret_id = sec["secret_id"] + secret_key = sec["secret_key"] + + assert sum(v is not None for v in (image_path, image_bytes, image_url)) == 1, \ + "必须且只能提供 image_path / image_bytes / image_url 之一" + + # 1. payload + payload: Dict[str, Any] = {} + if image_url: + payload["ImageUrl"] = image_url + else: + if image_bytes is None: + with open(image_path, "rb") as f: + image_bytes = f.read() + img_b64 = base64.b64encode(image_bytes).decode("utf-8") + img_b64 = TencentOCR._strip_data_uri_prefix(img_b64) + payload["ImageBase64"] = img_b64 + + payload_str = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + # 2. 参数准备 + algorithm = "TC3-HMAC-SHA256" + http_method = "POST" + canonical_uri = "/" + canonical_querystring = "" + content_type = "application/json; charset=utf-8" + signed_headers = "content-type;host;x-tc-action" + + timestamp, date = TencentOCR._now_ts_and_date() + credential_scope = f"{date}/{service}/tc3_request" + + # 3. 规范请求串 + canonical_headers = ( + f"content-type:{content_type}\n" + f"host:{host}\n" + f"x-tc-action:{action.lower()}\n" + ) + hashed_request_payload = hashlib.sha256(payload_str.encode("utf-8")).hexdigest() + canonical_request = ( + f"{http_method}\n{canonical_uri}\n{canonical_querystring}\n" + f"{canonical_headers}\n{signed_headers}\n{hashed_request_payload}" + ) + + # 4. 签名 + hashed_canonical_request = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest() + string_to_sign = ( + f"{algorithm}\n{timestamp}\n{credential_scope}\n{hashed_canonical_request}" + ) + secret_date = TencentOCR._hmac_sha256(("TC3" + secret_key).encode("utf-8"), date) + secret_service = hmac.new(secret_date, service.encode("utf-8"), hashlib.sha256).digest() + secret_signing = hmac.new(secret_service, b"tc3_request", hashlib.sha256).digest() + signature = hmac.new(secret_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest() + + authorization = ( + f"{algorithm} " + f"Credential={secret_id}/{credential_scope}, " + f"SignedHeaders={signed_headers}, " + f"Signature={signature}" + ) + + # 5. headers + headers = { + "Authorization": authorization, + "Content-Type": content_type, + "Host": host, + "X-TC-Action": action, + "X-TC-Timestamp": str(timestamp), + "X-TC-Version": version, + } + if region: + headers["X-TC-Region"] = region + if token: + headers["X-TC-Token"] = token + + # 6. 发请求 + try: + conn = HTTPSConnection(host, timeout=timeout) + conn.request("POST", "/", body=payload_str.encode("utf-8"), headers=headers) + resp = conn.getresponse() + raw = resp.read().decode("utf-8", errors="replace") + try: + data = json.loads(raw) + except Exception: + data = {"NonJSONBody": raw} + return { + "http_status": resp.status, + "http_reason": resp.reason, + "headers": dict(resp.getheaders()), + "body": data, + } + except socket.gaierror as e: + return {"error": "DNS_RESOLUTION_FAILED", "detail": str(e)} + except socket.timeout: + return {"error": "NETWORK_TIMEOUT", "detail": f"Timeout after {timeout}s"} + except Exception as e: + return {"error": "REQUEST_FAILED", "detail": str(e)} + finally: + try: + conn.close() + except Exception: + pass + + @staticmethod + def _norm(s: str) -> str: + return (s or "").strip().lstrip("@").lower() + + @staticmethod + def _rect_from_polygon(poly: List[Point]) -> Optional[ItemPolygon]: + if not poly: + return None + xs = [p["X"] for p in poly] + ys = [p["Y"] for p in poly] + return {"X": min(xs), "Y": min(ys), "Width": max(xs) - min(xs), "Height": max(ys) - min(ys)} + + @classmethod + def find_last_name_bbox(cls, ocr: Dict[str, Any], name: str) -> Optional[Dict[str, Any]]: + """ + 从 OCR JSON 中找到指定名字的“最后一次”出现并返回坐标信息。 + :param ocr: 完整 OCR JSON(含 Response.TextDetections) + :param name: 前端传入的名字,比如 'lee39160' + :return: dict 或 None,例如: + { + "index": 21, + "text": "lee39160", + "item": {"X": 248, "Y": 1701, "Width": 214, "Height": 49}, + "polygon": [...], + "center": {"x": 355.0, "y": 1725.5} + } + """ + dets = (ocr.get("body") or ocr).get("Response", {}).get("TextDetections", []) + if not dets or not name: + return None + + target = cls._norm(name) + found = -1 + + # 从后往前找最后一个严格匹配 + for i in range(len(dets) - 1, -1, -1): + txt = cls._norm(dets[i].get("DetectedText", "")) + if txt == target: + found = i + break + + # 兜底:再匹配原始文本(可能带 @) + if found == -1: + for i in range(len(dets) - 1, -1, -1): + raw = (dets[i].get("DetectedText") or "").strip().lower() + if raw.lstrip("@") == target: + found = i + break + + if found == -1: + return None + + det = dets[found] + item: Optional[ItemPolygon] = det.get("ItemPolygon") + poly: List[Point] = det.get("Polygon") or [] + + # 没有 ItemPolygon 就从 Polygon 算 + if not item: + item = cls._rect_from_polygon(poly) + if not item: + return None + + center = {"x": item["X"] + item["Width"] / 2.0, "y": item["Y"] + item["Height"] / 2.0} + + return { + "index": found, + "text": det.get("DetectedText", ""), + "item": item, + "polygon": poly, + "center": center, + } + + @staticmethod + def _get_detections(ocr: Dict[str, Any]) -> List[Dict[str, Any]]: + """兼容含 body 层的 OCR 结构,提取 TextDetections 列表""" + return (ocr.get("body") or ocr).get("Response", {}).get("TextDetections", []) or [] + + @staticmethod + def _norm_txt(s: str) -> str: + """清洗文本:去空格""" + return (s or "").strip() + + @classmethod + def slice_texts_between( + cls, + ocr: Dict[str, Any], + start_keyword: str = "切换账号", + end_keyword: str = "添加账号", + *, + username_like: bool = False, # True 时只保留像用户名的文本 + min_conf: int = 0 # 置信度下限 + ) -> List[Dict[str, Any]]: + """ + 返回位于 start_keyword 与 end_keyword 之间的所有文本项(不含两端), + 每项保留原始 DetectedText、Confidence、ItemPolygon 等信息。 + """ + dets = cls._get_detections(ocr) + if not dets: + return [] + + # 找“切换账号”最后一次出现的下标 + start_idx = -1 + for i, d in enumerate(dets): + txt = cls._norm_txt(d.get("DetectedText", "")) + if txt == start_keyword: + start_idx = i + + # 找“添加账号”第一次出现的下标 + end_idx = -1 + for i, d in enumerate(dets): + txt = cls._norm_txt(d.get("DetectedText", "")) + if txt == end_keyword: + end_idx = i + break + + if start_idx == -1 or end_idx == -1 or end_idx <= start_idx: + return [] + + # 提取两者之间的内容 + mid = [] + for d in dets[start_idx + 1:end_idx]: + if int(d.get("Confidence", 0)) < min_conf: + continue + txt = cls._norm_txt(d.get("DetectedText", "")) + if not txt: + continue + mid.append(d) + + if not username_like: + return mid + + # 只保留像用户名的文本 + pat = re.compile(r"^[A-Za-z0-9_.-]{3,}$") + filtered = [d for d in mid if pat.match(cls._norm_txt(d.get("DetectedText", "")))] + return filtered + + + + +if __name__ == "__main__": + result = TencentOCR.recognize( + image_path=r"C:\Users\zhangkai\Desktop\last-item\iosai\test.png", + action="GeneralAccurateOCR", + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + diff --git a/Utils/ThreadManager.py b/Utils/ThreadManager.py new file mode 100644 index 0000000..c39a897 --- /dev/null +++ b/Utils/ThreadManager.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- + +import threading +import ctypes +import inspect +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Dict, Optional, List, Tuple, Any + +from Utils.LogManager import LogManager + + +def _raise_async_exception(tid: int, exc_type) -> int: + if not inspect.isclass(exc_type): + raise TypeError("exc_type must be a class") + return ctypes.pythonapi.PyThreadState_SetAsyncExc( + ctypes.c_long(tid), ctypes.py_object(exc_type) + ) + + +def _kill_thread_by_tid(tid: Optional[int]) -> bool: + if tid is None: + return False + res = _raise_async_exception(tid, SystemExit) + if res == 0: + return False + if res > 1: + _raise_async_exception(tid, None) + raise SystemError("PyThreadState_SetAsyncExc affected multiple threads; reverted.") + return True + + +class ThreadManager: + """ + - add(udid, thread_or_target, *args, **kwargs) -> (code, msg) + - stop(udid, join_timeout=2.0, retries=5, wait_step=0.2) -> (code, msg) # 强杀 + - batch_stop(udids, join_timeout_each=2.0, retries_each=5, wait_step_each=0.2) -> (code, msg) + - get_thread / get_tid / is_running / list_udids + """ + _threads: Dict[str, threading.Thread] = {} + _lock = threading.RLock() + + # ========== 基础 ========== + @classmethod + def add(cls, udid: str, thread_or_target: Any, *args, **kwargs) -> Tuple[int, str]: + """ + 兼容两种用法: + 1) add(udid, t) # t 是 threading.Thread 实例 + 2) add(udid, target, *args, **kwargs) # target 是可调用 + 返回:(200, "创建任务成功") / (1001, "任务已存在") / (1001, "创建任务失败") + """ + with cls._lock: + exist = cls._threads.get(udid) + if exist and exist.is_alive(): + return 1001, "任务已存在" + + if isinstance(thread_or_target, threading.Thread): + t = thread_or_target + try: + t.daemon = True + except Exception: + pass + if not t.name: + t.name = f"task-{udid}" + + # 包装 run,退出时从表移除 + orig_run = t.run + def run_wrapper(): + try: + orig_run() + finally: + with cls._lock: + if cls._threads.get(udid) is t: + cls._threads.pop(udid, None) + t.run = run_wrapper # type: ignore + + else: + target = thread_or_target + def _wrapper(): + try: + target(*args, **kwargs) + finally: + with cls._lock: + cur = cls._threads.get(udid) + if cur is threading.current_thread(): + cls._threads.pop(udid, None) + t = threading.Thread(target=_wrapper, daemon=True, name=f"task-{udid}") + + try: + t.start() + except Exception: + return 1001, "创建任务失败" + + cls._threads[udid] = t + # 保留你原有的创建成功日志 + try: + LogManager.method_info(f"创建任务成功 [{udid}],线程ID={t.ident}", "task") + except Exception: + pass + return 200, "创建任务成功" + + @classmethod + def get_thread(cls, udid: str) -> Optional[threading.Thread]: + with cls._lock: + return cls._threads.get(udid) + + @classmethod + def get_tid(cls, udid: str) -> Optional[int]: + t = cls.get_thread(udid) + return t.ident if t else None + + @classmethod + def is_running(cls, udid: str) -> bool: + t = cls.get_thread(udid) + return bool(t and t.is_alive()) + + @classmethod + def list_udids(cls) -> List[str]: + with cls._lock: + return list(cls._threads.keys()) + + # ========== 内部:强杀一次 ========== + + @classmethod + def _stop_once(cls, udid: str, join_timeout: float, retries: int, wait_step: float) -> bool: + """ + 对指定 udid 执行一次强杀流程;返回 True=已停止/不存在,False=仍存活或被拒。 + """ + with cls._lock: + t = cls._threads.get(udid) + + if not t: + return True # 视为已停止 + + main_tid = threading.main_thread().ident + cur_tid = threading.get_ident() + if t.ident in (main_tid, cur_tid): + return False + + try: + _kill_thread_by_tid(t.ident) + except Exception: + pass + + if join_timeout < 0: + join_timeout = 0.0 + t.join(join_timeout) + + while t.is_alive() and retries > 0: + evt = threading.Event() + evt.wait(wait_step) + retries -= 1 + + dead = not t.is_alive() + if dead: + with cls._lock: + if cls._threads.get(udid) is t: + cls._threads.pop(udid, None) + return dead + + # ========== 对外:stop / batch_stop(均返回二元组) ========== + + @classmethod + def stop(cls, udid: str, join_timeout: float = 2.0, + retries: int = 5, wait_step: float = 0.2) -> Tuple[int, str]: + """ + 强杀单个:返回 (200, "stopped") 或 (1001, "failed") + """ + ok = cls._stop_once(udid, join_timeout, retries, wait_step) + if ok: + return 200, "stopped" + else: + return 1001, "failed" + + @classmethod + def batch_stop(cls, udids: List[str]) -> Tuple[int, str, List[str]]: + """ + 并行批量停止(简化版): + - 只接收 udids 参数 + - 其他参数写死:join_timeout=2.0, retries=5, wait_step=0.2 + - 所有设备同时执行,失败的重试 3 轮,每轮间隔 1 秒 + - 返回: + (200, "停止任务成功", []) + (1001, "停止任务失败", [失败udid...]) + """ + if not udids: + return 200, "停止任务成功", [] + + join_timeout = 2.0 + retries = 5 + wait_step = 0.2 + retry_rounds = 3 + round_interval = 1.0 + + def _stop_one(u: str) -> Tuple[str, bool]: + ok = cls._stop_once(u, join_timeout, retries, wait_step) + return u, ok + + # === 第一轮:并行执行所有设备 === + fail: List[str] = [] + with ThreadPoolExecutor(max_workers=len(udids)) as pool: + futures = [pool.submit(_stop_one, u) for u in udids] + for f in as_completed(futures): + u, ok = f.result() + if not ok: + fail.append(u) + + # === 对失败的设备重试 3 轮(每轮间隔 1 秒) === + for _ in range(retry_rounds): + if not fail: + break + time.sleep(round_interval) + remain: List[str] = [] + with ThreadPoolExecutor(max_workers=len(fail)) as pool: + futures = [pool.submit(_stop_one, u) for u in fail] + for f in as_completed(futures): + u, ok = f.result() + if not ok: + remain.append(u) + fail = remain + + # === 返回结果 === + if not fail: + return 200, "停止任务成功", [] + else: + return 1001, "停止任务失败", fail \ No newline at end of file diff --git a/resources/add.png b/resources/add.png new file mode 100644 index 0000000..aa6b19e Binary files /dev/null and b/resources/add.png differ diff --git a/resources/advertisement.png b/resources/advertisement.png new file mode 100644 index 0000000..87de404 Binary files /dev/null and b/resources/advertisement.png differ diff --git a/resources/back.png b/resources/back.png new file mode 100644 index 0000000..a679439 Binary files /dev/null and b/resources/back.png differ diff --git a/resources/comment.png b/resources/comment.png new file mode 100644 index 0000000..7043f81 Binary files /dev/null and b/resources/comment.png differ diff --git a/resources/comment2.png b/resources/comment2.png new file mode 100644 index 0000000..fa7a034 Binary files /dev/null and b/resources/comment2.png differ diff --git a/resources/icon.ico b/resources/icon.ico new file mode 100644 index 0000000..b40c169 Binary files /dev/null and b/resources/icon.ico differ diff --git a/resources/insert_comment.png b/resources/insert_comment.png new file mode 100644 index 0000000..b20f296 Binary files /dev/null and b/resources/insert_comment.png differ diff --git a/resources/insert_comment2.png b/resources/insert_comment2.png new file mode 100644 index 0000000..94c03b7 Binary files /dev/null and b/resources/insert_comment2.png differ diff --git a/resources/insert_comment2x.png b/resources/insert_comment2x.png new file mode 100644 index 0000000..2ee2f79 Binary files /dev/null and b/resources/insert_comment2x.png differ diff --git a/resources/ios.exe b/resources/ios.exe new file mode 100644 index 0000000..520f015 Binary files /dev/null and b/resources/ios.exe differ diff --git a/resources/iproxy/bz2.dll b/resources/iproxy/bz2.dll new file mode 100644 index 0000000..5b95ba8 Binary files /dev/null and b/resources/iproxy/bz2.dll differ diff --git a/resources/iproxy/getopt.dll b/resources/iproxy/getopt.dll new file mode 100644 index 0000000..18576c5 Binary files /dev/null and b/resources/iproxy/getopt.dll differ diff --git a/resources/iproxy/idevice_id.exe b/resources/iproxy/idevice_id.exe new file mode 100644 index 0000000..4d0da43 Binary files /dev/null and b/resources/iproxy/idevice_id.exe differ diff --git a/resources/iproxy/idevicebackup.exe b/resources/iproxy/idevicebackup.exe new file mode 100644 index 0000000..c2e0c9e Binary files /dev/null and b/resources/iproxy/idevicebackup.exe differ diff --git a/resources/iproxy/idevicebackup2.exe b/resources/iproxy/idevicebackup2.exe new file mode 100644 index 0000000..93be5d2 Binary files /dev/null and b/resources/iproxy/idevicebackup2.exe differ diff --git a/resources/iproxy/idevicebtlogger.exe b/resources/iproxy/idevicebtlogger.exe new file mode 100644 index 0000000..dcf0451 Binary files /dev/null and b/resources/iproxy/idevicebtlogger.exe differ diff --git a/resources/iproxy/idevicecrashreport.exe b/resources/iproxy/idevicecrashreport.exe new file mode 100644 index 0000000..a8d2eb4 Binary files /dev/null and b/resources/iproxy/idevicecrashreport.exe differ diff --git a/resources/iproxy/idevicedate.exe b/resources/iproxy/idevicedate.exe new file mode 100644 index 0000000..8333d7a Binary files /dev/null and b/resources/iproxy/idevicedate.exe differ diff --git a/resources/iproxy/idevicedebug.exe b/resources/iproxy/idevicedebug.exe new file mode 100644 index 0000000..2a8fb79 Binary files /dev/null and b/resources/iproxy/idevicedebug.exe differ diff --git a/resources/iproxy/idevicedebugserverproxy.exe b/resources/iproxy/idevicedebugserverproxy.exe new file mode 100644 index 0000000..f284c71 Binary files /dev/null and b/resources/iproxy/idevicedebugserverproxy.exe differ diff --git a/resources/iproxy/idevicedevmodectl.exe b/resources/iproxy/idevicedevmodectl.exe new file mode 100644 index 0000000..b02a9a6 Binary files /dev/null and b/resources/iproxy/idevicedevmodectl.exe differ diff --git a/resources/iproxy/idevicediagnostics.exe b/resources/iproxy/idevicediagnostics.exe new file mode 100644 index 0000000..155e132 Binary files /dev/null and b/resources/iproxy/idevicediagnostics.exe differ diff --git a/resources/iproxy/ideviceenterrecovery.exe b/resources/iproxy/ideviceenterrecovery.exe new file mode 100644 index 0000000..70d5d5e Binary files /dev/null and b/resources/iproxy/ideviceenterrecovery.exe differ diff --git a/resources/iproxy/ideviceimagemounter.exe b/resources/iproxy/ideviceimagemounter.exe new file mode 100644 index 0000000..5a650c9 Binary files /dev/null and b/resources/iproxy/ideviceimagemounter.exe differ diff --git a/resources/iproxy/ideviceinfo.exe b/resources/iproxy/ideviceinfo.exe new file mode 100644 index 0000000..55a4eed Binary files /dev/null and b/resources/iproxy/ideviceinfo.exe differ diff --git a/resources/iproxy/idevicename.exe b/resources/iproxy/idevicename.exe new file mode 100644 index 0000000..8abae73 Binary files /dev/null and b/resources/iproxy/idevicename.exe differ diff --git a/resources/iproxy/idevicenotificationproxy.exe b/resources/iproxy/idevicenotificationproxy.exe new file mode 100644 index 0000000..5303f2c Binary files /dev/null and b/resources/iproxy/idevicenotificationproxy.exe differ diff --git a/resources/iproxy/idevicepair.exe b/resources/iproxy/idevicepair.exe new file mode 100644 index 0000000..150d8ba Binary files /dev/null and b/resources/iproxy/idevicepair.exe differ diff --git a/resources/iproxy/ideviceprovision.exe b/resources/iproxy/ideviceprovision.exe new file mode 100644 index 0000000..1805e12 Binary files /dev/null and b/resources/iproxy/ideviceprovision.exe differ diff --git a/resources/iproxy/idevicerestore.exe b/resources/iproxy/idevicerestore.exe new file mode 100644 index 0000000..96974ff Binary files /dev/null and b/resources/iproxy/idevicerestore.exe differ diff --git a/resources/iproxy/idevicescreenshot.exe b/resources/iproxy/idevicescreenshot.exe new file mode 100644 index 0000000..c67f274 Binary files /dev/null and b/resources/iproxy/idevicescreenshot.exe differ diff --git a/resources/iproxy/idevicesetlocation.exe b/resources/iproxy/idevicesetlocation.exe new file mode 100644 index 0000000..21ec82a Binary files /dev/null and b/resources/iproxy/idevicesetlocation.exe differ diff --git a/resources/iproxy/idevicesyslog.exe b/resources/iproxy/idevicesyslog.exe new file mode 100644 index 0000000..2a81ef0 Binary files /dev/null and b/resources/iproxy/idevicesyslog.exe differ diff --git a/resources/iproxy/inetcat.exe b/resources/iproxy/inetcat.exe new file mode 100644 index 0000000..d0fa729 Binary files /dev/null and b/resources/iproxy/inetcat.exe differ diff --git a/resources/iproxy/iproxy.exe b/resources/iproxy/iproxy.exe new file mode 100644 index 0000000..3527899 Binary files /dev/null and b/resources/iproxy/iproxy.exe differ diff --git a/resources/iproxy/irecovery.exe b/resources/iproxy/irecovery.exe new file mode 100644 index 0000000..8982a6a Binary files /dev/null and b/resources/iproxy/irecovery.exe differ diff --git a/resources/iproxy/libcrypto-3.dll b/resources/iproxy/libcrypto-3.dll new file mode 100644 index 0000000..dce1596 Binary files /dev/null and b/resources/iproxy/libcrypto-3.dll differ diff --git a/resources/iproxy/libcurl.dll b/resources/iproxy/libcurl.dll new file mode 100644 index 0000000..773a027 Binary files /dev/null and b/resources/iproxy/libcurl.dll differ diff --git a/resources/iproxy/libssl-3.dll b/resources/iproxy/libssl-3.dll new file mode 100644 index 0000000..b9667cd Binary files /dev/null and b/resources/iproxy/libssl-3.dll differ diff --git a/resources/iproxy/plistutil.exe b/resources/iproxy/plistutil.exe new file mode 100644 index 0000000..d3e5a38 Binary files /dev/null and b/resources/iproxy/plistutil.exe differ diff --git a/resources/iproxy/readline.dll b/resources/iproxy/readline.dll new file mode 100644 index 0000000..3ea56da Binary files /dev/null and b/resources/iproxy/readline.dll differ diff --git a/resources/iproxy/zip.dll b/resources/iproxy/zip.dll new file mode 100644 index 0000000..95c3e78 Binary files /dev/null and b/resources/iproxy/zip.dll differ diff --git a/resources/iproxy/zlib1.dll b/resources/iproxy/zlib1.dll new file mode 100644 index 0000000..661bcc6 Binary files /dev/null and b/resources/iproxy/zlib1.dll differ diff --git a/resources/like.png b/resources/like.png new file mode 100644 index 0000000..46c6c25 Binary files /dev/null and b/resources/like.png differ diff --git a/resources/like1.png b/resources/like1.png new file mode 100644 index 0000000..db98202 Binary files /dev/null and b/resources/like1.png differ diff --git a/resources/search.png b/resources/search.png new file mode 100644 index 0000000..8aef0b5 Binary files /dev/null and b/resources/search.png differ diff --git a/resources/server.crt b/resources/server.crt new file mode 100644 index 0000000..f2f2494 --- /dev/null +++ b/resources/server.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDaTCCAlGgAwIBAgIUAQMZUx/qRQIv49P6EIWt+mjYlKYwDQYJKoZIhvcNAQEL +BQAwXTELMAkGA1UEBhMCQ04xDjAMBgNVBAgMBUxvY2FsMQ4wDAYDVQQHDAVMb2Nh +bDEMMAoGA1UECgwDRGV2MQwwCgYDVQQLDANEZXYxEjAQBgNVBAMMCWxvY2FsaG9z +dDAeFw0yNTExMTgwNjMyMjlaFw0zNTExMTYwNjMyMjlaMF0xCzAJBgNVBAYTAkNO +MQ4wDAYDVQQIDAVMb2NhbDEOMAwGA1UEBwwFTG9jYWwxDDAKBgNVBAoMA0RldjEM +MAoGA1UECwwDRGV2MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDxY0znIdwlVW+EVcQww7Xg/16iN3JpX0svpEOIbo1u +40S3bRn057cS//1AG+c+55iisfOaoUX59ZUQhihcAje7IfTKO1/dCzDy4d/PT2hl +UOO9Zo8GQzflFM0U6fIi2Ifly09JTGJEyxr+SrJHcJPENualiR6zwNLlqupE9bDP +40ydznYWZRvw3N0QmrkOg+eY6FwaYtWspvf/KiJWucscc31zGyA0MhF552k6sIVg +9Vskr9Bd3g52Umv/1yPZmESkuM905ImCwSCK0VPAY+rooUeTYw3ktE7q/iy5+l71 +s6hN9YHVo3m4pIJz4G0YT039TnjtZxxHt8IIVSv6Ymr3AgMBAAGjITAfMB0GA1Ud +DgQWBBQ6Q3V0wyXXyNm3jvmSKc2KoAMvAzANBgkqhkiG9w0BAQsFAAOCAQEAacsO +ja4qpX/vWUTelhdvzg5alD5WDrP8iSIXmGF+HSHgJbbjxbDm4vlMZjzwh8iqODQR +yJ9iuRiFFXGCktEqFx2NTCIUBmyoBg/LFeLtOn0Ncqs11ypoSoRxqE0IaeDjirBH +hNUIXzJ+3pOqgyHU+3WqgEzEjW63pNmjX1esVZqA0SQJejsv4hJOvBzGoFFgSRcC +Zp7NrusZ8IDkdLbUgD9pgZHPI8YNH/MVocV3wd45o9Y3nkMPhIqkp/1GOWIdN6qj +co66o0hYsJduQC9fEBceWpRNWUirEKd231SeaW9vZMPMrfmOZanDY6pXdvhsZR3L +9ZGsJk6ktoTagz2AYw== +-----END CERTIFICATE----- diff --git a/resources/server.key b/resources/server.key new file mode 100644 index 0000000..e8fe6b8 --- /dev/null +++ b/resources/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDxY0znIdwlVW+E +VcQww7Xg/16iN3JpX0svpEOIbo1u40S3bRn057cS//1AG+c+55iisfOaoUX59ZUQ +hihcAje7IfTKO1/dCzDy4d/PT2hlUOO9Zo8GQzflFM0U6fIi2Ifly09JTGJEyxr+ +SrJHcJPENualiR6zwNLlqupE9bDP40ydznYWZRvw3N0QmrkOg+eY6FwaYtWspvf/ +KiJWucscc31zGyA0MhF552k6sIVg9Vskr9Bd3g52Umv/1yPZmESkuM905ImCwSCK +0VPAY+rooUeTYw3ktE7q/iy5+l71s6hN9YHVo3m4pIJz4G0YT039TnjtZxxHt8II +VSv6Ymr3AgMBAAECgf9Wf6myg+iV5TRN77TAGbAP6udHacgYn+7Ad5eQC+ZlobuU +Y3Tnh325tXqvPRFSGFwiAlMQVDECt1u/PDm6vJ077t/UHQ5zjr3sYPU1oONuptJ7 +Jo0WsoFKLkH/oMeBi7h643+Oo9/GH04/nTdnMS9kqL1lYy/aUOVGW5JXIZUWhwMc +gTEXuyuaajqdRbQex6vSQANpauYMewj9jlU9RVfjB6LGUHLDJwWbi61dTOYakxkn +ssfzEDPMFuGQ8qjG5Li6ceUgNHlmY73WZBXNHKt6FcwNqldy5FB4PiIxoWx0ZHmq +z/i89BQlurp20RyFD8f+iMTIbU/z0gHM86JkziECgYEA+qO5KVSg0XQlQmsyXT7Z +nKI+DakGxn4ipJWuUDLdi0QoymNIF3diUi1o9P4Q8QzfNFCTVdUsQft24ekXAkJX +QtGL61BXBJX5vZvihbBuCEGatE+31LRQZOxWLaeWE6y8XbFfez22SRpa+IrTma+h +BxBRyKSqO03xyojKTPPMbDkCgYEA9ozsY02MUJKDXxtE2BRgwGQumoHi7HPRUISk +ij0MWw/AYdYoCsrALP4IKB65tK0M7lJsK+g5M2Kyh8XD/Z4hfT1oNnJW8SOtPBwd +heuPUzjnhIhnnxoLFeKMo6bGNqVkIYAOKbcX9s2J7U9NwUlYxMxmjNrV2/Nb2MFE +Raun8K8CgYBbX1ydaLDIKyN6N0JBJCyJIcylhj1mF43hmn/V1PVXVB3ayp75jxhV +BSECT601c4/brpRH8lMUKuyIJ0WwGSdewK3Vt5BBp7tIGJBYVJ2IfQI9QeKutJ2q +bU5tjm7z9UEmlwdMEo9lzyni+hlyKcj2nkhycTVuMVg4ke3OaALaYQKBgDD3XBt8 +01lNP/ormEiyA2UygG7/TOpZNkEflu49oa6UOkk0F0/NZM2KxmPxdkCD/gV3KTSv +Mm0aNQryJDLCrTQKdiAaJVpPE6DUlKh8WELXEmQoEyxuJ7V5ASWfgc5omrJslGOE +kaXavIH9NhwlTRQI5HUlIURF2P/7omuT5A7RAoGBAJhtThrzxExrzmf4bTIXe+EQ +M5lAPqIn46ThlujcNCLroxWz32Ekzs1ywn2NKwK6F1gMJEsFXqQDXtumJYTJ94tY +L1fv0knHgRnURsq6xZ4IQgq3YzV0OXQwN4PBzqcRR6sMgFAXfCzXMy/eE7iz8eZT +FsrkWh/9tgVvITaUrqr5 +-----END PRIVATE KEY----- diff --git a/resources/wintun.dll b/resources/wintun.dll new file mode 100644 index 0000000..aee04e7 Binary files /dev/null and b/resources/wintun.dll differ diff --git a/script/ScriptManager.py b/script/ScriptManager.py new file mode 100644 index 0000000..7078a70 --- /dev/null +++ b/script/ScriptManager.py @@ -0,0 +1,2174 @@ +import random +import re +import threading +import time +from pathlib import Path + +import wda +import os +from datetime import datetime +from Entity import Variables +from Utils.AiUtils import AiUtils +from Utils.ControlUtils import ControlUtils +from Utils.CountryEnum import CountryLanguageMapper +from Utils.IOSAIStorage import IOSAIStorage +from Utils.JsonUtils import JsonUtils +from Utils.LogManager import LogManager +from Entity.Variables import anchorList +from Utils.OCRUtils import OCRUtils +from Utils.Requester import Requester +import Entity.Variables as ev +from Utils.TencentOCRUtils import TencentOCR + + +# 脚本管理类 +class ScriptManager(): + # # 单利对象 + # _instance = None # 类变量,用于存储单例实例 + # + # def __new__(cls): + # # 如果实例不存在,则创建一个新实例 + # if cls._instance is None: + # cls._instance = super(ScriptManager, cls).__new__(cls) + # # 返回已存在的实例 + # return cls._instance + _device_cache = {} + _cache_lock = threading.Lock() # 线程安全锁(可选,如果你有多线程) + + @classmethod + def get_screen_info(cls, udid: str): + # 如果缓存中没有该设备的信息,则获取并缓存 + if udid not in cls._device_cache: + with cls._cache_lock: # 防止并发写入 + if udid not in cls._device_cache: # 双重检查 + cls._device_cache[udid] = AiUtils._screen_info(udid) + return cls._device_cache[udid] + + def __init__(self): + super().__init__() + + # 初始化获取模版所在的地址 + current_dir = Path(__file__).resolve().parent + # 项目根目录(假设你的类文件在项目的子目录里,比如 Module/OCR/OCRUtils.py) + self.lock = threading.Lock() + project_root = current_dir.parent # 如果你确定这个文件就在项目根目录下,可省略这行 + # resources 文件夹路径 + # 获取相应的模板的地址 + self.resources_dir = project_root / "resources" + + # === 2. @2x 素材 (scale=2) === + self.comment_dir2 = self.resources_dir / "comment2.png" + self.comment_add_dir_2x = self.resources_dir / "insert_comment2x.png" + self.comment_add_dir2_2x = self.resources_dir / "insert_comment2x.png" + + # === 3. @3x 素材 (scale=3) === + self.comment_dir = self.resources_dir / "comment.png" + self.comment_add_dir = self.resources_dir / "insert_comment.png" + self.comment_add_dir2 = self.resources_dir / "insert_comment2.png" + + self.initialized = True # 标记已初始化 + + def _pick_template(self, scale: float): + """ + scale≈3 -> 返回 @3x 素材,除 3 + 其余默认 @2x 素材,除 2 + """ + if abs(scale - 3.0) < 0.3: # 3x + return (self.comment_dir, + self.comment_add_dir, + self.comment_add_dir2, # 3x 兜底 + 3) # 除数 + else: # 2x(默认) + return (self.comment_dir2, + self.comment_add_dir_2x, + self.comment_add_dir2_2x, # 2x 兜底 + 2) # 除数 + + def comment_flow(self, filePath, session, udid, recomend_cx, recomend_cy): + width, height, scale = self.get_screen_info(udid) + + # 取当前分辨率的三张图 + 除数 + comment_tpl, add_tpl, add_fb_tpl, div = self._pick_template(scale) + + # ① 点评论按钮 + coord = OCRUtils.find_template(str(comment_tpl), filePath) + + LogManager.method_info(f"使用的模板路径是:{str(comment_tpl)}", "养号", udid) + + if not coord: + print("无法检测到评论按钮") + LogManager.method_info("无法检测到评论按钮", "养号", udid) + return + + cx, cy = coord[0] + session.click(int(cx / div), int(cy / div)) + LogManager.method_info(f"点击评论坐标:{int(cx / div)}, {int(cy / div)}", "养号", udid) + time.sleep(2) + + # ② 重新截图(防止键盘弹起) + img = session.screenshot() + filePath = os.path.join(os.path.dirname(filePath), "bgv_comment.png") + img.save(filePath) + + # ③ 随机评论语 + single_comment = random.choice(Variables.commentList) if Variables.commentList else "评论没有导入数据" + + # ④ 找「添加评论」按钮 + coord2 = OCRUtils.find_template(str(add_tpl), filePath) + click_count = False + if coord2: # 方案 1 命中 + cx2, cy2 = coord2[0] + session.tap(int(cx2 / div), int(cy2 / div)) + session.send_keys(f"{single_comment}\n") + click_count = True + LogManager.method_info("评论成功(方案1)", "养号", udid) + else: # 方案 2 兜底 + time.sleep(1) + img = session.screenshot() + img.save(filePath) + coord3 = OCRUtils.find_template(str(add_fb_tpl), filePath) + if coord3: + cx3, cy3 = coord3[0] + session.tap(int(cx3 / div), int(cy3 / div)) + session.send_keys(f"{single_comment}\n") + click_count = True + LogManager.method_info("评论成功(方案2)", "养号", udid) + + # ⑤ 返回 / 取消 + # tap_x = int(recomend_cx) if recomend_cx else 100 + # tap_y = int(recomend_cy) if recomend_cy else 100 + + time.sleep(1) + + session.tap(100, 100) + + if not click_count: # 兜底多点一次 + time.sleep(1) + session.tap(100, 100) + + # 养号 + def growAccount(self, udid, isComment, event, is_monitoring=False): + LogManager.method_info(f"调用刷视频", "养号", udid) + + # ========= 初始化 ========= + client = wda.USBClient(udid, ev.wdaFunctionPort) + session = client.session() + AiUtils.makeUdidDir(udid) + + while not event.is_set(): + try: + if not is_monitoring: + LogManager.method_info(f"开始养号,重启tiktok", "养号", udid) + ControlUtils.closeTikTok(session, udid) + event.wait(timeout=1) + ControlUtils.openTikTok(session, udid) + event.wait(timeout=3) + + recomend_cx = 0 + recomend_cy = 0 + # ========= 主循环 ========= + while not event.is_set(): + # 设置手机的节点深度为15,判断该页面是否正确 + session.appium_settings({"snapshotMaxDepth": 15}) + el = session.xpath( + '//XCUIElementTypeButton[@name="top_tabs_recomend" or @name="推荐" or @label="推荐"]' + ) + # 获取推荐按钮所在的坐标 + if el.exists: + bounds = el.bounds # 返回 [x, y, width, height] + recomend_cx = bounds[0] + bounds[2] // 2 + recomend_cy = bounds[1] + bounds[3] // 2 + + if not el.exists: + # 记录日志 + LogManager.method_error("找不到推荐按钮,养号出现问题,重启养号功能", "养号", udid=udid) + # 手动的抛出异常 重启流程 + raise Exception("找不到推荐按钮,养号出现问题,重启养号功能") + + if el.value != "1": + LogManager.method_error("当前页面不是推荐页面,养号出现问题,重启养号功能", "养号", udid=udid) + raise Exception("当前页面不是推荐页面,养号出现问题,重启养号功能") + + LogManager.method_info("当前页面是推荐页面,开始养号", "养号", udid=udid) + # 重新设置节点的深度,防止手机进行卡顿 + session.appium_settings({"snapshotMaxDepth": 0}) + + # ---- 截图保存 ---- + try: + img = client.screenshot() + base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + resource_dir = os.path.join(base_dir, "resources", udid) + os.makedirs(resource_dir, exist_ok=True) + filePath = os.path.join(resource_dir, "bgv.png") + img.save(filePath) + LogManager.method_info(f"保存屏幕图像成功 -> {filePath}", "养号", udid) + print("保存了背景图:", filePath) + event.wait(timeout=1) + except Exception as e: + LogManager.method_info(f"截图或保存失败,失败原因:{e}", "养号", udid) + raise Exception("截图或保存失败,重启养号功能") + + # ---- 视频逻辑 ---- + try: + width, height, scale = self.get_screen_info(udid) + if scale == 3.0: + addX, addY = AiUtils.findImageInScreen("add", udid) + else: + addX, addY = AiUtils.findImageInScreen("like1", udid) + + isSame = False + for i in range(2): + if scale == 3.0: + tx, ty = AiUtils.findImageInScreen("add", udid) + else: + tx, ty = AiUtils.findImageInScreen("like1", udid) + + if addX == tx and addY == ty: + isSame = True + event.wait(timeout=1) + else: + isSame = False + # break + + if addX > 0 and isSame: + needLike = random.randint(0, 100) + homeButton = AiUtils.findHomeButton(session) + if homeButton: + LogManager.method_info("有首页按钮,查看视频", "养号", udid) + videoTime = random.randint(5, 15) + LogManager.method_info("准备停止脚本", method="task") + for _ in range(videoTime): # 0.2 秒一片 + if event.is_set(): + LogManager.method_info("停止脚本中", method="task") + break + event.wait(timeout=1) + LogManager.method_info("停止脚本成功", method="task") + + # 重置 session + session.appium_settings({"snapshotMaxDepth": 0}) + + if needLike < 25: + LogManager.method_info("进行点赞", "养号", udid) + ControlUtils.clickLike(session, udid) + LogManager.method_info("继续观看视频", "养号", udid) + LogManager.method_info("准备划到下一个视频", "养号", udid) + else: + LogManager.method_error("找不到首页按钮。出错了", "养号", udid) + + if isComment and random.random() > 0.70: + self.comment_flow(filePath, session, udid, recomend_cx, recomend_cy) + event.wait(timeout=2) + + home = AiUtils.findHomeButton(session) + if not home: + raise Exception("没有找到首页按钮,重置") + + videoTime = random.randint(15, 30) + for _ in range(videoTime): + if event.is_set(): + break + event.wait(timeout=1) + + ControlUtils.swipe_up(client) + + # 如果is_monitoring 为False 则说明和监控消息没有联动,反正 则证明和监控消息进行联动 + if is_monitoring: + # 监控消息按钮,判断是否有消息 + el = session.xpath( + '//XCUIElementTypeButton[@name="a11y_vo_inbox"]' + ' | ' + '//XCUIElementTypeButton[contains(@name,"收件箱")]' + ' | ' + '//XCUIElementTypeButton[.//XCUIElementTypeStaticText[@value="收件箱"]]' + ) + # 判断收件箱是否有消息 + if el.exists: + try: + m = re.search(r'(\d+)', el.label) # 抓到的第一个数字串 + except Exception as e: + LogManager.method_error(f"解析收件箱数量异常: {e}", "检测消息", udid) + + count = int(m.group(1)) if m else 0 + if count: + break + else: + continue + + except Exception as e: + print("刷视频脚本有错误:错误内容:", e) + LogManager.method_error(f"刷视频过程出现错误,重试", "养号", udid) + raise e # 抛出给上层,触发重生机制 + + except Exception as e: + print("刷视频遇到错误了。错误内容:", e) + LogManager.method_error(f"[{udid}] 养号出现异常,将重启流程: {e}", "养号", udid) + event.wait(timeout=3) + + # 观看直播 + def watchLiveForGrowth(self, udid, event, max_retries=None): + import random, wda + retry_count = 0 + backoff_sec = 10 + + # —— 每次重启都新建 client/session —— + client = wda.USBClient(udid, ev.wdaFunctionPort) + session = client.session() + + while not event.is_set(): + try: + + # 1) 先关再开 + ControlUtils.closeTikTok(session, udid) + event.wait(timeout=1) + ControlUtils.openTikTok(session, udid) + event.wait(timeout=3) + + session.appium_settings({"snapshotMaxDepth": 15}) + # 2) 进入直播 (使用英文) + live_button = session( + xpath='//XCUIElementTypeButton[@name="LIVE" or @label="LIVE" or @name="直播" or @label="直播"] ' + '| //XCUIElementTypeOther[@name="LIVE" or @label="LIVE" or @name="直播" or @label="直播"]' + ) + + if live_button.exists: + live_button.click() + else: + LogManager.method_error("无法找到直播间按钮 抛出异常 重新启动", "直播养号", udid) + # 抛出异常 + raise Exception(f"找不到直播按钮,抛出异常 重新启动") + waitTime = random.randint(15, 20) + for _ in range(waitTime): # 0.2 秒一片 + if event.is_set(): + break + event.wait(timeout=1) + + # live_button = session(xpath='//XCUIElementTypeButton[@name="直播"]') + live_button = session( + xpath='//XCUIElementTypeButton[@name="LIVE" or @label="LIVE" or @name="直播" or @label="直播"] ' + '| //XCUIElementTypeOther[@name="LIVE" or @label="LIVE" or @name="直播" or @label="直播"]' + ) + + if live_button.exists: + continue + + # 下滑一下 + ControlUtils.swipe_up(client) + + # 3) 取分辨率;可选重建 session 规避句柄陈旧 + size = session.window_size() + width, height = size.width, size.height + + # 4) 主循环:刷直播 + while not event.is_set(): + event.wait(timeout=3) + + # 找到一个看直播的时候肯定有的元素,当这个元素没有的时候,就代表当前的页面出现了问题 + # 需要抛出异常,重启这个流程 + el1 = session.xpath('//XCUIElementTypeOther[@name="GBLFeedRootViewComponent"]') + el2 = session.xpath('//XCUIElementTypeOther[@value="0%"]') + + if not (el1.exists or el2.exists): + print("当前页面不是直播间,重启刷直播") + LogManager.method_error("当前页面不是直播间,重启刷直播", "直播养号", udid=udid) + raise Exception("当前页面不是直播间") + else: + print("当前页面是直播间,继续刷直播") + LogManager.method_info("当前页面是直播间,继续刷直播", "直播养号", udid=udid) + + # PK 直接划走 + if session(xpath='//XCUIElementTypeOther[@name="kGBLInteractionViewMatchScoreBar"]').exists: + print("✅ 当前是 PK,跳过") + LogManager.method_info("✅ 当前是 PK,跳过", "直播养号", udid=udid) + ControlUtils.swipe_up(client) + continue + + # 计算直播显示窗口数量(主画面+连麦小窗) + count = AiUtils.count_add_by_xml(session) + print(f"检测到直播显示区域窗口数:{count}") + + if count > 1: + print("❌ 多窗口(有人连麦/分屏),划走") + LogManager.method_info("❌ 多窗口(有人连麦/分屏),划走", "直播养号", udid=udid) + ControlUtils.swipe_up(client) + continue + else: + print("✅ 单窗口,(10%概率)开始点赞") + LogManager.method_info("✅ 单窗口,(3%概率)开始点赞", "直播养号", udid=udid) + + # 随机点赞(仍保留中途保护) + if random.random() >= 0.97: + print("开始点赞") + LogManager.method_info("开始点赞", "直播养号", udid=udid) + for _ in range(random.randint(10, 30)): + # 中途转PK/连麦立即跳过 + if session(xpath='//XCUIElementTypeOther[@name="kGBLInteractionViewMatchScoreBar"]').exists \ + or AiUtils.count_add_by_xml(session) > 1: + print("❗ 中途发生 PK/连麦,跳过") + LogManager.method_info("❗ 中途发生 PK/连麦,跳过", "直播养号", udid=udid) + ControlUtils.swipe_up(client) + break + x = width // 3 + random.randint(-10, 10) + y = height // 3 + random.randint(10, 20) + print("双击坐标:", x, y) + + session.double_tap(x, y) + + print("--------------------------------------------") + # 换成 + total_seconds = random.randint(300, 600) + for _ in range(total_seconds): # 0.2 秒一片 + if event.is_set(): + break + event.wait(timeout=1) + + ControlUtils.swipe_up(client) + + # 正常退出(外部 event 触发) + break + + except Exception as e: + retry_count += 1 + LogManager.method_error(f"watchLiveForGrowth 异常(第{retry_count}次):{repr(e)}", "直播养号", udid) + # 尝试轻量恢复一次,避免一些短暂性 session 失效 + try: + client = wda.USBClient(udid, ev.wdaFunctionPort) + _ = client.session() + except Exception: + pass + # 冷却后整段流程重来 + for _ in range(backoff_sec): # 0.2 秒一片 + if event.is_set(): + break + event.wait(timeout=1) + continue + + """ + 外层包装,出现异常自动重试、 + 关注打招呼以及回复主播消息 + """ + + def safe_greetNewFollowers(self, udid, needReply, isComment, needTranslate, event): + retries = 0 + while not event.is_set(): + try: + self.greetNewFollowers(udid, needReply, isComment, needTranslate, event) + except Exception as e: + retries += 1 + LogManager.method_error(f"greetNewFollowers 出现异常: {e},准备第 {retries} 次重试", "关注打招呼", udid) + event.wait(timeout=3) + + if event.is_set(): + LogManager.method_info("外层 while 检测到停止,即将 break", "关注打招呼", udid) + break + print("任务终止") + LogManager.method_error("greetNewFollowers任务终止", "关注打招呼", udid) + + # 关注打招呼 + def greetNewFollowers(self, udid, needReply, isComment, needTranslate, event): + + client = wda.USBClient(udid, ev.wdaFunctionPort) + session = client.session() + + print(f"是否要自动回复消息:{needReply}") + + LogManager.method_info(f"是否要自动回复消息:{needReply}", "关注打招呼", udid) + + # 先关闭Tik Tok + ControlUtils.closeTikTok(session, udid) + event.wait(timeout=1) + + # 重新打开Tik Tok + ControlUtils.openTikTok(session, udid) + event.wait(timeout=3) + LogManager.method_info(f"重启tiktok", "关注打招呼", udid) + + # 设置查找深度 + session.appium_settings({"snapshotMaxDepth": 15}) + + session.appium_settings({"waitForQuiescence": False}) + + # 创建udid名称的目录 + AiUtils.makeUdidDir(udid) + + # 返回上一步 + def goBack(count): + for i in range(count): + LogManager.method_info(f"返回上一步", "关注打招呼", udid) + session.appium_settings({"snapshotMaxDepth": 15}) + + ControlUtils.clickBack(session) + event.wait(timeout=2) + + LogManager.method_info(f"循环条件1:{not event.is_set()}", "关注打招呼", udid) + LogManager.method_info(f"循环条件2:{len(anchorList) > 0}", "关注打招呼", udid) + LogManager.method_info(f"循环条件3:{not event.is_set() and len(anchorList) > 0}", "关注打招呼", udid) + + # 循环条件。1、 循环关闭 2、 数据处理完毕 + while not event.is_set(): + print(f"关注打招呼开始循环,设备是:{udid}") + session.appium_settings({"waitForQuiescence": False}) + + LogManager.method_info("=== 外层 while 新一轮 ===", "关注打招呼", udid) + if event.is_set(): + break + with self.lock: + # 获取一个主播, + LogManager.method_info(f"开始获取数据", "关注打招呼", udid) + + # 获取一个主播, + result = AiUtils.peek_aclist_first() + LogManager.method_info(f"数据是:{result}", "关注打招呼", udid) + + state = result.get("state", 0) + + if not state: + LogManager.method_info(f"当前主播的状态是:{state} 不通行,取出数据移到列表尾部 继续下一个", + "关注打招呼", + udid) + AiUtils.pop_aclist_first(mode="move") + continue + + # 并删除 + anchor = AiUtils.pop_aclist_first() + LogManager.method_info(f"当前主播的状态是:{state} 通行,取出数据删除", "关注打招呼", udid) + + if not anchor: + LogManager.method_info(f"数据库中的数据不足", "关注打招呼", udid) + if not self.interruptible_sleep(event, 30): + continue + + aid = anchor.get("anchorId", "") + anchorCountry = anchor.get("country", "") + + LogManager.method_info(f"主播的数据,用户名:{aid},国家:{anchorCountry}", "关注打招呼", udid) + + # 点击搜索按钮 + ControlUtils.clickSearch(session) + + LogManager.method_info(f"点击搜索按钮", "关注打招呼", udid) + + # 强制刷新session + session.appium_settings({"snapshotMaxDepth": 15}) + + # 查找输入框 + input = session.xpath('//XCUIElementTypeSearchField') + + if event.is_set(): + break + + # 如果找到了输入框,就点击并且输入内容 + if input.exists: + input.click() + # 稍作停顿 + event.wait(timeout=0.5) + else: + print(f"找不到输入框") + raise Exception("找不到输入框") + + input = session.xpath('//XCUIElementTypeSearchField') + if input.exists: + input.clear_text() + event.wait(timeout=1) + # 输入主播id + LogManager.method_info(f"输入主播id:{aid or '暂无数据'}", "关注打招呼", udid) + input.set_text(f"{aid or '暂无数据'}\n") + + # 定位 "关注" 按钮 通过关注按钮的位置点击主播首页 + + session.appium_settings({"snapshotMaxDepth": 25}) + + try: + if event.is_set(): + break + + # 点击进入首页 + ControlUtils.clickFollow(session, aid) + + if event.is_set(): + break + LogManager.method_info("点击进入主播首页", "关注打招呼", udid) + except wda.WDAElementNotFoundError: + LogManager.method_info("未找到进入主播首页的按钮,使用第二个方案。", "关注打招呼", udid) + enter_room = ControlUtils.userClickProfile(session, aid) + if not enter_room: + goBack(2) + session.appium_settings({"snapshotMaxDepth": 15}) + continue + + event.wait(timeout=5) + # 找到并点击第一个视频 + + if event.is_set(): + break + + cellClickResult, workCount = ControlUtils.clickFirstVideoFromDetailPage(session) + + LogManager.method_info(f"是否有视频:{cellClickResult} 作品的数量为:{workCount}", "关注打招呼", udid) + + LogManager.method_info(f"点击第一个视频", "关注打招呼", udid) + + event.wait(timeout=2) + + # 观看主播视频 + def viewAnchorVideo(workCount): + print("开始查看视频,并且重新调整查询深度") + LogManager.method_info("开始查看视频,调整节点深度为5", "关注打招呼", udid) + + session.appium_settings({"snapshotMaxDepth": 5}) + + if workCount > 3: + count = 3 + else: + count = workCount + + while count != 0: + if event.is_set(): + break + + LogManager.method_info("准备停止脚本", method="task") + for _ in range(5): + LogManager.method_info("停止脚本中", method="task") + if event.is_set(): + break + event.wait(timeout=1) + LogManager.method_info("停止脚本成功", method="task") + img = client.screenshot() + event.wait(timeout=1) + + # filePath = f"resources/{udid}/bgv.png" + + base_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) # 当前脚本目录的上一级 + filePath = os.path.join(base_dir, "resources", udid, "bgv.png") + dirPath = os.path.dirname(filePath) + if not os.path.exists(dirPath): + os.makedirs(dirPath) + + img.save(filePath) + + LogManager.method_info("保存屏幕图像成功", "关注打招呼", udid) + event.wait(timeout=2) + # 查找add图标 + LogManager.method_info("开始点击点赞", "关注打招呼", udid) + ControlUtils.clickLike(session, udid) + + count -= 1 + LogManager.method_info("准备停止脚本", method="task") + # 随机看视频 15~30秒 + for _ in range(random.randint(15, 30)): + LogManager.method_info("停止脚本中", method="task") + if event.is_set(): + break + event.wait(timeout=1) + LogManager.method_info("停止脚本成功", method="task") + + LogManager.method_info(f"是否进行评论:{isComment}", "关注打招呼", udid) + # 使用OCR进行评论 + if isComment: + LogManager.method_info("调用方法进行评论", "关注打招呼", udid) + self.comment_flow(filePath, session, udid, 100, 100) + event.wait(timeout=2) + + session.appium_settings({"snapshotMaxDepth": 12}) + LogManager.method_info(f"检查当前是否为视频页面", "关注打招呼", udid) + # 最多尝试 3 次(第一次 + 再试两次) + for attempt in range(3): + is_back_enabled = ControlUtils.isClickBackEnabled(session) + if is_back_enabled: # 成功就立即跳出 + break + + # 失败日志 + LogManager.method_info(f"返回失败,第 {attempt + 1} 次检查失败", "关注打招呼", udid) + + # 最后一次失败不再点击,直接抛异常 + if attempt == 2: + LogManager.method_info("返回失败,重启", "关注打招呼", udid) + raise Exception("返回失败,出现问题") + + # 前两次失败:点一下再等 1 秒,进入下一次循环 + session.tap(100, 100) + time.sleep(1) + + if count != 0: + ControlUtils.swipe_up(client) + + # 右滑返回 + # client.swipe_right() + + back_btn = ControlUtils.clickBack(session) + + if not back_btn: + print("返回失败,出现问题") + raise Exception("返回失败,出现问题") + + if event.is_set(): + LogManager.method_info("viewAnchorVideo 检测到停止,提前退出", "关注打招呼", udid) + return + + if event.is_set(): + break + # 如果打开视频失败。说明该主播没有视频 + if cellClickResult == True: + # 观看主播视频 + LogManager.method_info("去查看主播视频", "关注打招呼", udid) + viewAnchorVideo(workCount) + event.wait(timeout=3) + LogManager.method_info("视频看完了,重置试图查询深度", "关注打招呼", udid) + session.appium_settings({"snapshotMaxDepth": 25}) + event.wait(timeout=0.5) + # 向上滑动 + ControlUtils.swipe_down(udid) + + event.wait(timeout=2) + + if event.is_set(): + break + + msgButton = AiUtils.getSendMesageButton(session) + event.wait(timeout=2) + + if msgButton.exists: + # 进入聊天页面 + msgButton.click() + LogManager.method_info("找到发消息按钮了", "关注打招呼", udid) + print("找到发消息按钮了") + else: + LogManager.method_info("没有识别出发消息按钮", "关注打招呼", udid) + print("没有识别出发消息按钮") + goBack(3) + session.appium_settings({"snapshotMaxDepth": 15}) + continue + + event.wait(timeout=3) + # 查找聊天界面中的输入框节点 + chatInput = session.xpath("//TextView") + if chatInput.exists: + + print("找到输入框了, 准备发送一条打招呼消息") + LogManager.method_info("找到输入框了, 准备发送一条打招呼消息", "关注打招呼", udid) + + # LogManager.method_info(f"传递的打招呼的数据:{ev.prologueList}", "关注打招呼", udid) + + # 取出国家进行对应国家语言代码 + anchorCountry_code = CountryLanguageMapper.get_language_code(anchorCountry) + + LogManager.method_info(f"获取的语言代码是:{ev.prologueList}", "关注打招呼", udid) + + LogManager.method_info(f"存储的打招呼语句是:{ev.prologueList}", "关注打招呼", udid) + + # 判断对应的语言代码是否在传入的字典中 + if anchorCountry_code in ev.prologueList: + + LogManager.method_info(f"在存储的字典中 打招呼语句是:{ev.prologueList}", "关注打招呼", udid) + + # 进行原本的进行传入 + privateMessageList = ev.prologueList[anchorCountry_code] + text = random.choice(privateMessageList) + + msg = text + + else: + + LogManager.method_info(f"不在存储的字典中 打招呼语句是:{ev.prologueList}", "关注打招呼", udid) + # 需要翻译 + privateMessageList = ev.prologueList['yolo'] + # 准备打招呼的文案 + text = random.choice(privateMessageList) + + LogManager.method_info(f"取出打招呼的数据,{text}", "关注打招呼", + udid) + + if needTranslate: + LogManager.method_info(f"需要翻译:{text},参数为:国家为{anchorCountry}, 即将进行翻译", + "关注打招呼", udid) + msg = Requester.translation(text, anchorCountry) + LogManager.method_info(f"翻译成功:{msg}, ", "关注打招呼", udid) + else: + msg = text + LogManager.method_info(f"即将发送的私信内容:{msg}", "关注打招呼", udid) + + if event.is_set(): + break + # 准备发送一条信息 + chatInput = session.xpath("//TextView") + if chatInput.exists: + chatInput.click() + LogManager.method_info(f"即将发送的私信内容:{msg or '暂无数据'}", "关注打招呼", udid) + + chatInput.set_text(f"{msg or '暂无数据'}\n") + event.wait(timeout=2) + # 发送消息 + # input.set_text(f"{aid or '暂无数据'}\n") + event.wait(timeout=1) + else: + print("无法发送信息") + LogManager.method_info(f"给主播{aid} 发送消息失败", "关注打招呼", udid) + + # 接着下一个主播 + goBack(1) + + # 点击关注按钮 + # followButton = AiUtils.getFollowButton(session).get(timeout=5) + # if followButton is not None: + # # LogManager.method_info("找到关注按钮了", "关注打招呼", udid) + # # followButton.click() + # x, y, w, h = followButton.bounds + # cx = int(x + w / 2) + # cy = int(y + h / 2) + # # 随机偏移 ±5 px(可自己改范围) + # cx += random.randint(-5, 5) + # cy += random.randint(-5, 5) + # + # session.click(cx, cy) + # + # else: + # LogManager.method_info("没找到关注按钮", "关注打招呼", udid) + # time.sleep(1) + # goBack(4) + # session.appium_settings({"snapshotMaxDepth": 15}) + # continue + if event.is_set(): + break + session.appium_settings({"snapshotMaxDepth": 15}) + goBack(3) + + else: + print(f"{aid}:该主播没有视频") + LogManager.method_info(f"{aid}:该主播没有视频", "关注打招呼", udid) + goBack(3) + session.appium_settings({"snapshotMaxDepth": 15}) + continue + + # 设置查找深度 + session.appium_settings({"snapshotMaxDepth": 15}) + event.wait(timeout=2) + print("即将要回复消息") + LogManager.method_info("即将要回复消息", "关注打招呼", udid) + + LogManager.method_info(f"是否需要进行监控消息:{needReply}", "监控消息") + if event.is_set(): + break + + if needReply: + print("如果需要回复主播消息。走此逻辑") + + print("----------------------------------------------------------") + LogManager.method_info(f"进入准备监控消息方法", "监控消息") + # 执行回复消息逻辑 + self.monitorMessages(session, udid, event) + + homeButton = AiUtils.findHomeButton(session) + if homeButton.exists: + homeButton.click() + else: + ControlUtils.closeTikTok(session, udid) + event.wait(timeout=2) + ControlUtils.openTikTok(session, udid) + event.wait(timeout=3) + + # 执行完成之后。继续点击搜索 + session.appium_settings({"snapshotMaxDepth": 15}) + else: + session.appium_settings({"snapshotMaxDepth": 15}) + print("任务终止2") + + print("greetNewFollowers方法执行完毕") + + def safe_followAndGreetUnion(self, udid, needReply, needTranslate, event): + + retries = 0 + while not event.is_set(): + try: + self.followAndGreetUnion(udid, needReply, needTranslate, event) + + except Exception as e: + retries += 1 + LogManager.method_error(f"greetNewFollowers 出现异常: {e},准备第 {retries} 次重试", "关注打招呼", udid) + event.wait(timeout=3) + + if event.is_set(): + LogManager.method_info("外层 while 检测到停止,即将 break", "关注打招呼", udid) + break + LogManager.method_error("greetNewFollowers 重试次数耗尽,任务终止", "关注打招呼", udid) + + # 关注打招呼以及回复主播消息(联盟号) + def followAndGreetUnion(self, udid, needReply, needTranslate, event): + + client = wda.USBClient(udid, ev.wdaFunctionPort) + session = client.session() + + print(f"是否要自动回复消息:{needReply}") + + LogManager.method_info(f"是否要自动回复消息:{needReply}", "关注打招呼(联盟号)", udid) + + # 先关闭Tik Tok + ControlUtils.closeTikTok(session, udid) + event.wait(timeout=1) + + # 重新打开Tik Tok + ControlUtils.openTikTok(session, udid) + event.wait(timeout=3) + LogManager.method_info(f"重启tiktok", "关注打招呼(联盟号)", udid) + # 设置查找深度 + session.appium_settings({"snapshotMaxDepth": 15}) + + # 创建udid名称的目录 + AiUtils.makeUdidDir(udid) + + # 返回上一步 + def goBack(count): + for i in range(count): + LogManager.method_info(f"返回上一步", "关注打招呼(联盟号)", udid) + + session.appium_settings({"snapshotMaxDepth": 15}) + ControlUtils.clickBack(session) + event.wait(timeout=2) + + LogManager.method_info(f"循环条件1:{not event.is_set()}", "关注打招呼(联盟号)", udid) + LogManager.method_info(f"循环条件2:{len(anchorList) > 0}", "关注打招呼(联盟号)", udid) + LogManager.method_info(f"循环条件3:{not event.is_set() and len(anchorList) > 0}", "关注打招呼(联盟号)", udid) + + # 循环条件。1、 循环关闭 2、 数据处理完毕 + while not event.is_set(): + + LogManager.method_info("=== 外层 while 新一轮 ===", "关注打招呼(联盟号)", udid) + if event.is_set(): + break + + + + with self.lock: + # 获取一个主播, + LogManager.method_info(f"开始获取数据", "关注打招呼(联盟号)", udid) + # 获取一个主播, + result = AiUtils.peek_aclist_first() + LogManager.method_info(f"数据是:{result}", "关注打招呼(联盟号)", udid) + + state = result.get("state", 0) + if not state: + LogManager.method_info(f"当前主播的状态是:{state} 不通行,取出数据移到列表尾部 继续下一个", + "关注打招呼(联盟号)", + udid) + AiUtils.pop_aclist_first(mode="move") + continue + + # 并删除 + anchor = AiUtils.pop_aclist_first() + LogManager.method_info(f"当前主播的状态是:{state} 通行,取出数据删除", "关注打招呼(联盟号)", udid) + + if not anchor: + LogManager.method_info(f"数据库中的数据不足", "关注打招呼(联盟号)", udid) + if not self.interruptible_sleep(event, 30): + continue + + aid = anchor.get("anchorId", "") + anchorCountry = anchor.get("country", "") + + LogManager.method_info(f"主播的数据,用户名:{aid},国家:{anchorCountry}", "关注打招呼(联盟号)", udid) + + # 点击搜索按钮 + ControlUtils.clickSearch(session) + + LogManager.method_info(f"点击搜索按钮", "关注打招呼(联盟号)", udid) + + # 强制刷新session + session.appium_settings({"snapshotMaxDepth": 15}) + + # 查找输入框 + input = session.xpath('//XCUIElementTypeSearchField') + + # 如果找到了输入框,就点击并且输入内容 + if input.exists: + input.click() + # 稍作停顿 + event.wait(timeout=0.5) + else: + print(f"找不到输入框") + raise Exception("找不到输入框") + + input = session.xpath('//XCUIElementTypeSearchField') + if input.exists: + input.clear_text() + event.wait(timeout=1) + # 输入主播id + LogManager.method_info(f"搜索主播名称:{aid or '暂无数据'}", "关注打招呼(联盟号)", udid) + + input.set_text(f"{aid or '暂无数据'}\n") + + # 定位 "关注" 按钮 通过关注按钮的位置点击主播首页 + + session.appium_settings({"snapshotMaxDepth": 25}) + + try: + # 点击进入首页 + ControlUtils.clickFollow(session, aid) + LogManager.method_info("点击进入主播首页", "关注打招呼(联盟号)", udid) + except wda.WDAElementNotFoundError: + LogManager.method_info("未找到进入主播首页的按钮,使用第二个方案。", "关注打招呼(联盟号)", udid) + enter_room = ControlUtils.userClickProfile(session, aid) + if not enter_room: + goBack(2) + session.appium_settings({"snapshotMaxDepth": 15}) + continue + + event.wait(timeout=2) + + session.appium_settings({"snapshotMaxDepth": 25}) + event.wait(timeout=0.5) + # 向上滑动 + ControlUtils.swipe_down(udid) + + event.wait(timeout=2) + msgButton = AiUtils.getSendMesageButton(session) + event.wait(timeout=2) + + if msgButton.exists: + # 进入聊天页面 + msgButton.click() + LogManager.method_info("找到发消息按钮了", "关注打招呼(联盟号)", udid) + print("找到发消息按钮了") + else: + LogManager.method_info("没有识别出发消息按钮", "关注打招呼(联盟号)", udid) + print("没有识别出发消息按钮") + goBack(3) + session.appium_settings({"snapshotMaxDepth": 15}) + continue + + event.wait(timeout=3) + # 查找聊天界面中的输入框节点 + chatInput = session.xpath("//TextView") + if chatInput.exists: + + print("找到输入框了, 准备发送一条打招呼消息") + LogManager.method_info("找到输入框了, 准备发送一条打招呼消息", "关注打招呼(联盟号)", udid) + + # 取出国家进行对应国家语言代码 + anchorCountry_code = CountryLanguageMapper.get_language_code(anchorCountry) + print(anchorCountry_code) + + print("存储的是:", ev.prologueList) + # 判断对应的语言代码是否在传入的字典中 + if anchorCountry_code in ev.prologueList: + # 进行原本的进行传入 + privateMessageList = ev.prologueList[anchorCountry_code] + text = random.choice(privateMessageList) + msg = text + else: + # 从yolo中拿取 + privateMessageList = ev.prologueList['yolo'] + text = random.choice(privateMessageList) + + if needTranslate: + # 翻译成主播国家的语言 + LogManager.method_info(f"需要翻译:{text},参数为:国家为{anchorCountry}, 即将进行翻译", + "关注打招呼(联盟号)", udid) + + msg = Requester.translation(text, anchorCountry) + + LogManager.method_info(f"翻译成功:{msg}, ", "关注打招呼(联盟号)", udid) + + else: + msg = text + LogManager.method_info(f"即将发送的私信内容:{msg}", "关注打招呼(联盟号)", udid) + + # 准备发送一条信息 + chatInput = session.xpath("//TextView") + if chatInput.exists: + chatInput.click() + LogManager.method_info(f"即将发送的私信内容:{msg or '暂无数据'}", "关注打招呼(联盟号)", udid) + + chatInput.set_text(f"{msg or '暂无数据'}\n") + event.wait(timeout=2) + # 发送消息 + # input.set_text(f"{aid or '暂无数据'}\n") + event.wait(timeout=1) + else: + print("无法发送信息") + LogManager.method_info(f"给主播{aid} 发送消息失败", "关注打招呼(联盟号)", udid) + + # 接着下一个主播 + goBack(1) + + # 点击关注按钮 + # followButton = AiUtils.getFollowButton(session).get(timeout=5) + # if followButton is not None: + # # LogManager.method_info("找到关注按钮了", "关注打招呼", udid) + # # followButton.click() + # x, y, w, h = followButton.bounds + # cx = int(x + w / 2) + # cy = int(y + h / 2) + # # 随机偏移 ±5 px(可自己改范围) + # cx += random.randint(-5, 5) + # cy += random.randint(-5, 5) + # + # ControlUtils.tap_mini_cluster(cx, cy, session) + # + # else: + # LogManager.method_info("没找到关注按钮", "关注打招呼", udid) + # time.sleep(1) + # goBack(4) + # session.appium_settings({"snapshotMaxDepth": 15}) + # continue + + session.appium_settings({"snapshotMaxDepth": 15}) + goBack(3) + + # 设置查找深度 + session.appium_settings({"snapshotMaxDepth": 15}) + event.wait(timeout=2) + print("即将要回复消息") + LogManager.method_info("即将要回复消息", "关注打招呼(联盟号)", udid) + + if needReply: + print("如果需要回复主播消息。走此逻辑") + + print("----------------------------------------------------------") + print("监控回复消息") + + # 执行回复消息逻辑 + self.monitorMessages(session, udid, event) + + homeButton = AiUtils.findHomeButton(session) + if homeButton.exists: + homeButton.click() + else: + ControlUtils.closeTikTok(session, udid) + event.wait(timeout=2) + ControlUtils.openTikTok(session, udid) + event.wait(timeout=3) + + print("重新创建wda会话 防止wda会话失效") + # 执行完成之后。继续点击搜索 + session.appium_settings({"snapshotMaxDepth": 15}) + else: + session.appium_settings({"snapshotMaxDepth": 15}) + + print("greetNewFollowers方法执行完毕") + + # 检测消息 + def replyMessages(self, udid, event): + + try: + client = wda.USBClient(udid, ev.wdaFunctionPort) + session = client.session() + except Exception as e: + LogManager.method_error(f"创建wda会话异常: {e}", "检测消息", udid) + return + + LogManager.method_info("开始重启tiktok", "监控消息") + ControlUtils.closeTikTok(session, udid) + event.wait(timeout=2) + # time.sleep(1) + ControlUtils.openTikTok(session, udid) + event.wait(timeout=3) + # time.sleep(1) + LogManager.method_info("重启tiktok成功", "监控消息") + + while not event.is_set(): + try: + # 调用检测消息的方法 + self.monitorMessages(session, udid, event) + except Exception as e: + LogManager.method_error(f"监控消息 出现异常: {e},重新启动监控直播", "检测消息", udid) + LogManager.method_info(f"出现异常时,稍等再重启 TikTok 并重试 异常是: {e}", "监控消息", udid) + LogManager.method_info(f"出现异常,重新创建wda", "监控消息", udid) + LogManager.method_info(f"重启 TikTok", "监控消息", udid) + # 出现异常时,稍等再重启 TikTok 并重试 + ControlUtils.closeTikTok(session, udid) + event.wait(timeout=2) + # time.sleep(1) + ControlUtils.openTikTok(session, udid) + event.wait(timeout=3) + # time.sleep(1) + LogManager.method_info("TikTok 重启成功", "监控消息", udid) + continue # 重新进入 while 循环,调用 monitorMessages + + # def monitorMessages(self, session, udid, event): + # + # LogManager.method_info("脚本开始执行中", "监控消息") + # + # # 调整节点的深度为 7 + # session.appium_settings({"snapshotMaxDepth": 7}) + # + # el = session.xpath( + # '//XCUIElementTypeButton[@name="a11y_vo_inbox"]' + # ' | ' + # '//XCUIElementTypeButton[contains(@name,"收件箱")]' + # ' | ' + # '//XCUIElementTypeButton[.//XCUIElementTypeStaticText[@value="收件箱"]]' + # ) + # + # # 如果收件箱有消息 则进行点击 + # if el.exists: + # try: + # m = re.search(r'(\d+)', el.label) # 抓到的第一个数字串 + # except Exception as e: + # LogManager.method_error(f"解析收件箱数量异常: {e}", "检测消息", udid) + # + # count = int(m.group(1)) if m else 0 + # + # if count: + # el.click() + # session.appium_settings({"snapshotMaxDepth": 25}) + # event.wait(timeout=3) + # while True: + # print("循环开始") + # info_count = 0 + # # 创建新的会话 + # el = session.xpath( + # '//XCUIElementTypeButton[@name="a11y_vo_inbox"]' + # ' | ' + # '//XCUIElementTypeButton[contains(@name,"收件箱")]' + # ' | ' + # '//XCUIElementTypeButton[.//XCUIElementTypeStaticText[@value="收件箱"]]' + # ) + # + # if not el.exists: + # LogManager.method_error(f"检测不到收件箱", "检测消息", udid) + # raise Exception("当前页面找不到收件箱,重启") + # # break + # + # # 支持中文“收件箱”和英文“Inbox” + # xpath_query = ( + # "//XCUIElementTypeStaticText" + # "[@value='收件箱' or @label='收件箱' or @name='收件箱'" + # " or @value='Inbox' or @label='Inbox' or @name='Inbox']" + # ) + # + # # 查找所有收件箱节点 + # inbox_nodes = session.xpath(xpath_query).find_elements() + # if len(inbox_nodes) < 2: + # LogManager.method_error(f"当前页面不再收件箱页面,重启", "检测消息", udid) + # raise Exception("当前页面不再收件箱页面,重启") + # + # m = re.search(r'(\d+)', el.label) # 抓到的第一个数字串 + # count = int(m.group(1)) if m else 0 + # + # if not count: + # LogManager.method_info(f"当前收件箱的总数量{count}", "检测消息", udid) + # break + # + # # 新粉丝 + # xp_new_fan_badge = ( + # "//XCUIElementTypeCell[.//XCUIElementTypeLink[@name='新粉丝']]" + # "//XCUIElementTypeStaticText[string-length(@value)>0 and translate(@value,'0123456789','')='']" + # ) + # + # # 活动 + # xp_activity_badge = ( + # "//XCUIElementTypeCell[.//XCUIElementTypeLink[@name='活动']]" + # "//XCUIElementTypeStaticText[string-length(@value)>0 and translate(@value,'0123456789','')='']" + # ) + # + # # 系统通知 + # xp_system_badge = ( + # "//XCUIElementTypeCell[.//XCUIElementTypeLink[@name='系统通知']]" + # "//XCUIElementTypeStaticText[string-length(@value)>0 and translate(@value,'0123456789','')='']" + # ) + # + # # 消息请求 + # xp_request_badge = ( + # "//XCUIElementTypeCell" + # "[.//*[self::XCUIElementTypeLink or self::XCUIElementTypeStaticText]" + # " [@name='消息请求' or @label='消息请求' or @value='消息请求']]" + # "//XCUIElementTypeStaticText[string-length(@value)>0 and translate(@value,'0123456789','')='']" + # ) + # + # # 用户消息 + # xp_badge_numeric = ( + # "(" + # # 你的两类未读容器组件 + 数字徽标(value 纯数字) + # "//XCUIElementTypeOther[" + # " @name='AWEIMChatListCellUnreadCountViewComponent'" + # " or @name='TikTokIMImpl.InboxCellUnreadCountViewBuilder'" + # "]//XCUIElementTypeStaticText[@value and translate(@value,'0123456789','')='']" + # ")/ancestor::XCUIElementTypeCell[1]" + # " | " + # # 兜底:任何在 CollectionView 下、value 纯数字的徽标 → 找其最近的 Cell + # "//XCUIElementTypeCollectionView//XCUIElementTypeStaticText" + # "[@value and translate(@value,'0123456789','')='']" + # "/ancestor::XCUIElementTypeCell[1]" + # ) + # + # try: + # # 如果 2 秒内找不到,会抛异常 + # user_text = session.xpath(xp_badge_numeric).get(timeout=2.0) + # val = (user_text.info.get("value") or + # user_text.info.get("label") or + # user_text.info.get("name")) + # LogManager.method_info(f"用户未读数量:{val}", "检测消息", udid) + # except Exception: + # LogManager.method_warning("当前屏幕没有找到 用户 未读徽标数字", "检测消息", udid) + # print("当前屏幕没有找到 用户消息 未读徽标数字", udid) + # user_text = None + # info_count += 1 + # + # if user_text: + # + # user_text.tap() + # event.wait(timeout=3) + # + # + # + # xml = session.source() + # time.sleep(1) + # msgs = AiUtils.extract_messages_from_xml(xml) + # time.sleep(1) + # + # last_in = None # 对方最后一句 + # last_out = None # 我方最后一句 + # attempt = 0 # 已试次数(首次算第 1 次) + # + # while attempt < 3: + # # 1. 本轮扫描 + # for item in reversed(msgs): + # if item.get('type') != 'msg': + # continue + # if last_in is None and item['dir'] == 'in': + # last_in = item['text'] + # if last_out is None and item['dir'] == 'out': + # last_out = item['text'] + # if last_in and last_out: + # break + # + # # 2. 有一条为空就重试 + # if not last_in or not last_out: + # attempt += 1 + # if attempt == 3: + # break # 三次用完,放弃 + # time.sleep(0.2) + # xml = session.source() + # msgs = AiUtils.extract_messages_from_xml(xml) + # continue + # else: + # break # 至少一条有内容,成功退出 + # + # LogManager.method_info(f"检测到对方最后发送的消息:{last_in}", "检测消息", udid) + # LogManager.method_info(f"检测我发送的最后一条信息:{last_out}", "检测消息", udid) + # + # + # # 获取主播的名称 + # # anchor_name = AiUtils.get_navbar_anchor_name(session) + # anchor_name = "" + # for _ in range(3): # 最多 2 次重试 + 1 次初始 + # anchor_name = AiUtils.get_navbar_anchor_name(session) + # if anchor_name: + # break + # time.sleep(1) + # + # LogManager.method_info(f"获取主播的名称:{anchor_name}", "检测消息", udid) + # LogManager.method_info(f"获取主播最后发送的消息 即将翻译:{last_in}", "检测消息", udid) + # + # if last_in is not None: + # chinese_last_msg_text = Requester.translationToChinese(last_in) + # else: + # chinese_last_msg_text = "" + # + # # 进行判断,判断翻译后是否 + # + # LogManager.method_info(f"翻译中文后的内容,交给前端进行展示:{chinese_last_msg_text}", "检测消息", + # udid) + # + # # 找到输入框 + # last_data = [{ + # "sender": anchor_name, + # "device": udid, + # "time": datetime.now().strftime("%Y-%m-%d %H:%M"), + # "text": chinese_last_msg_text, + # "status": 0 + # }] + # + # LogManager.method_info(f"主播最后发送的数据,传递给前端进行记录:{chinese_last_msg_text}", + # "检测消息", udid) + # + # # 把主播的名称存储到c盘 + # JsonUtils.append_json_items(last_data, "log/last_message.json") + # + # # 从C盘中读取数据 + # anchorWithSession = IOSAIStorage.load() + # + # sel = session.xpath("//TextView") + # if anchor_name not in anchorWithSession: + # + # # 如果是第一次发消息(没有sessionId的情况) + # LogManager.method_info(f"第一次发消息:{anchor_name},没有记忆 开始请求ai", "检测消息", udid) + # LogManager.method_info(f"向ai发送的参数: 文本为:{last_in}", "检测消息", udid) + # + # if last_in is None: + # LogManager.method_info(f"检测不到对方发送的最后一条消息,发送一条打招呼", "检测消息", + # udid) + # + # text = "ok" + # if last_out: + # text = last_out + # + # if sel.exists: + # sel.click() # 聚焦 + # event.wait(timeout=1) + # sel.clear_text() + # + # LogManager.method_info( + # f"发送的消息,检测不到对方发送的消息,不走ai:{text or '暂无数据'}", "检测消息", + # udid) + # + # sel.set_text(f"{text or '暂无数据'}\n") + # else: + # LogManager.method_error("找不到输入框,重启", "检测消息", udid) + # raise Exception("找不到输入框,重启") + # else: + # aiResult, sessionId = Requester.chatToAi({"query": last_in, "user": "1"}) + # IOSAIStorage.save({anchor_name: sessionId}, mode="merge") + # + # # 找到输入框,输入ai返回出来的消息 + # + # if sel.exists: + # sel.click() # 聚焦 + # event.wait(timeout=1) + # sel.clear_text() + # LogManager.method_info( + # f"发送的消息,检测到对方发送的消息,进行走ai(没记忆):{aiResult or '暂无数据'}", + # "检测消息", + # udid) + # sel.set_text(f"{aiResult or '暂无数据'}\n") + # else: + # LogManager.method_error("找不到输入框,重启", "检测消息", udid) + # raise Exception("找不到输入框,重启") + # else: + # print("有记忆") + # + # LogManager.method_info(f"不是一次发消息:{anchor_name},有记忆", "检测消息", udid) + # # 如果不是第一次发消息(证明存储的有sessionId) + # sessionId = anchorWithSession[anchor_name] + # + # if last_in is None: + # last_in = "ok" + # if sel.exists: + # sel.click() # 聚焦 + # event.wait(timeout=1) + # sel.clear_text() + # LogManager.method_info( + # f"发送的消息,检测到对方发送的消息,进行走ai(有记忆):{last_in or '暂无数据'}", + # "检测消息", + # udid) + # sel.set_text(f"{last_in or '暂无数据'}\n") + # else: + # + # # TODO: user后续添加,暂时写死 + # + # LogManager.method_info(f"向ai发送的参数: 文本为:{last_in}", "检测消息", udid) + # + # aiResult, sessionId = Requester.chatToAi( + # {"query": last_in, "conversation_id": sessionId, "user": "1"}) + # + # if sel.exists: + # sel.click() # 聚焦 + # event.wait(timeout=1) + # sel.clear_text() + # LogManager.method_info( + # f"发送的消息,检测到对方发送的消息,进行走ai(有记忆):{aiResult or '暂无数据'}", + # "检测消息", + # udid) + # sel.set_text(f"{aiResult or '暂无数据'}\n") + # + # LogManager.method_info(f"存储的sessionId:{anchorWithSession}", "检测消息", udid) + # event.wait(timeout=1) + # + # # 返回 + # ControlUtils.clickBack(session) + # + # # 重新回到收件箱页面后,强制刷新节点 + # session.appium_settings({"snapshotMaxDepth": 25}) + # event.wait(timeout=1) + # + # try: + # # 如果 2 秒内找不到,会抛异常 + # badge_text = session.xpath(xp_new_fan_badge).get(timeout=2.0) + # val = (badge_text.info.get("value") or + # badge_text.info.get("label") or + # badge_text.info.get("name")) + # + # LogManager.method_info(f"新粉丝未读数量:{val}", "检测消息", udid) + # if badge_text: + # badge_text.tap() + # event.wait(timeout=1) + # ControlUtils.clickBack(session) + # event.wait(timeout=1) + # except Exception: + # LogManager.method_warning("当前屏幕没有找到 新粉丝 未读徽标数字", "检测消息", udid) + # print("当前屏幕没有找到 新粉丝 未读徽标数字", udid) + # badge_text = None + # info_count += 1 + # + # try: + # # 如果 2 秒内找不到,会抛异常 + # badge_text = session.xpath(xp_activity_badge).get(timeout=2.0) + # val = (badge_text.info.get("value") or + # badge_text.info.get("label") or + # badge_text.info.get("name")) + # LogManager.method_info(f"活动未读数量:{val}", "检测消息", udid) + # if badge_text: + # badge_text.tap() + # event.wait(timeout=1) + # ControlUtils.clickBack(session) + # event.wait(timeout=1) + # except Exception: + # LogManager.method_warning("当前屏幕没有找到 活动 未读徽标数字", "检测消息", udid) + # print("当前屏幕没有找到 活动 未读徽标数字", udid) + # badge_text = None + # info_count += 1 + # + # try: + # # 如果 2 秒内找不到,会抛异常 + # badge_text = session.xpath(xp_system_badge).get(timeout=2.0) + # val = (badge_text.info.get("value") or + # badge_text.info.get("label") or + # badge_text.info.get("name")) + # LogManager.method_info(f"系统通知未读数量:{val}", "检测消息", udid) + # if badge_text: + # badge_text.tap() + # event.wait(timeout=1) + # ControlUtils.clickBack(session) + # event.wait(timeout=1) + # except Exception: + # LogManager.method_warning("当前屏幕没有找到 系统通知 未读徽标数字", "检测消息", udid) + # print("当前屏幕没有找到 系统通知 未读徽标数字", udid) + # badge_text = None + # info_count += 1 + # + # try: + # # 如果 2 秒内找不到,会抛异常 + # badge_text = session.xpath(xp_request_badge).get(timeout=2.0) + # val = (badge_text.info.get("value") or + # badge_text.info.get("label") or + # badge_text.info.get("name")) + # LogManager.method_info(f"消息请求未读数量:{val}", "检测消息", udid) + # if badge_text: + # badge_text.tap() + # event.wait(timeout=1) + # ControlUtils.clickBack(session) + # event.wait(timeout=1) + # except Exception: + # LogManager.method_warning("当前屏幕没有找到 消息请求 未读徽标数字", "检测消息", udid) + # print("当前屏幕没有找到 消息请求 未读徽标数字", udid) + # badge_text = None + # info_count += 1 + # + # # 双击收件箱 定位到消息的位置 + # if info_count == 5: + # r = el.bounds # 可能是命名属性,也可能是 tuple + # cx = int((r.x + r.width / 2) if hasattr(r, "x") else (r[0] + r[2] / 2)) + # cy = int((r.y + r.height / 2) if hasattr(r, "y") else (r[1] + r[3] / 2)) + # session.double_tap(cx, cy) # 可能抛异常:方法不存在 + # LogManager.method_info(f"双击收件箱 定位到信息", "检测消息", udid) + # else: + # + # return + # else: + # LogManager.method_error(f"检测不到收件箱", "检测消息", udid) + # raise Exception("当前页面找不到收件箱,重启") + def monitorMessages(self, session, udid, event): + + LogManager.method_info("脚本开始执行中", "监控消息") + + # 调整节点的深度为 7 + session.appium_settings({"snapshotMaxDepth": 7}) + + el = session.xpath( + '//XCUIElementTypeButton[@name="a11y_vo_inbox"]' + ' | ' + '//XCUIElementTypeButton[contains(@name,"收件箱")]' + ' | ' + '//XCUIElementTypeButton[.//XCUIElementTypeStaticText[@value="收件箱"]]' + ) + + # 如果收件箱有消息 则进行点击 + if el.exists: + try: + m = re.search(r'(\d+)', el.label) # 抓到的第一个数字串 + except Exception as e: + LogManager.method_error(f"解析收件箱数量异常: {e}", "检测消息", udid) + + count = int(m.group(1)) if m else 0 + + if count: + el.click() + session.appium_settings({"snapshotMaxDepth": 25}) + event.wait(timeout=3) + while True: + print("循环开始") + info_count = 0 + # 创建新的会话 + el = session.xpath( + '//XCUIElementTypeButton[@name="a11y_vo_inbox"]' + ' | ' + '//XCUIElementTypeButton[contains(@name,"收件箱")]' + ' | ' + '//XCUIElementTypeButton[.//XCUIElementTypeStaticText[@value="收件箱"]]' + ) + + if not el.exists: + LogManager.method_error(f"检测不到收件箱", "检测消息", udid) + raise Exception("当前页面找不到收件箱,重启") + # break + + # 支持中文“收件箱”和英文“Inbox” + xpath_query = ( + "//XCUIElementTypeStaticText" + "[@value='收件箱' or @label='收件箱' or @name='收件箱'" + " or @value='Inbox' or @label='Inbox' or @name='Inbox']" + ) + + # 查找所有收件箱节点 + inbox_nodes = session.xpath(xpath_query).find_elements() + if len(inbox_nodes) < 2: + LogManager.method_error(f"当前页面不再收件箱页面,重启", "检测消息", udid) + raise Exception("当前页面不再收件箱页面,重启") + + m = re.search(r'(\d+)', el.label) # 抓到的第一个数字串 + count = int(m.group(1)) if m else 0 + + if not count: + LogManager.method_info(f"当前收件箱的总数量{count}", "检测消息", udid) + break + + # 新粉丝 + xp_new_fan_badge = ( + "//XCUIElementTypeCell[.//XCUIElementTypeLink[@name='新粉丝']]" + "//XCUIElementTypeStaticText[string-length(@value)>0 and translate(@value,'0123456789','')='']" + ) + + # 活动 + # xp_activity_badge = ( + # "//XCUIElementTypeCell[.//XCUIElementTypeLink[@name='活动']]" + # "//XCUIElementTypeStaticText[string-length(@value)>0 and translate(@value,'0123456789','')='']" + # ) + xp_activity_badge = ( + "//XCUIElementTypeLink[@name='活动']/ancestor::XCUIElementTypeCell[1]" + "//XCUIElementTypeStaticText[" + " @value and " + " (translate(@value,'0123456789','')='' or @value='99+')" + "]" + ) + + # 系统通知 + xp_system_badge = ( + "//XCUIElementTypeCell[.//XCUIElementTypeLink[@name='系统通知']]" + "//XCUIElementTypeStaticText[string-length(@value)>0 and translate(@value,'0123456789','')='']" + ) + + # 消息请求 + xp_request_badge = ( + "//XCUIElementTypeCell" + "[.//*[self::XCUIElementTypeLink or self::XCUIElementTypeStaticText]" + " [@name='消息请求' or @label='消息请求' or @value='消息请求']]" + "//XCUIElementTypeStaticText[string-length(@value)>0 and translate(@value,'0123456789','')='']" + ) + + # 用户消息 + xp_badge_numeric = ( + "(" + # 你的两类未读容器组件 + 数字徽标(value 纯数字) + "//XCUIElementTypeOther[" + " @name='AWEIMChatListCellUnreadCountViewComponent'" + " or @name='TikTokIMImpl.InboxCellUnreadCountViewBuilder'" + "]//XCUIElementTypeStaticText[@value and translate(@value,'0123456789','')='']" + ")/ancestor::XCUIElementTypeCell[1]" + ) + + try: + # 如果 2 秒内找不到,会抛异常 + user_text = session.xpath(xp_badge_numeric).get(timeout=2.0) + val = (user_text.info.get("value") or + user_text.info.get("label") or + user_text.info.get("name")) + LogManager.method_info(f"用户未读数量:{val}", "检测消息", udid) + except Exception: + LogManager.method_warning("当前屏幕没有找到 用户 未读徽标数字", "检测消息", udid) + print("当前屏幕没有找到 用户消息 未读徽标数字", udid) + user_text = None + info_count += 1 + + if user_text: + print("点击进入用户主页") + user_text.tap() + event.wait(timeout=3) + + xml = session.source() + time.sleep(1) + msgs = AiUtils.extract_messages_from_xml(xml) + time.sleep(1) + + last_in = None # 对方最后一句 + last_out = None # 我方最后一句 + attempt = 0 # 已试次数(首次算第 1 次) + + while attempt < 3: + # 1. 本轮扫描 + for item in reversed(msgs): + if item.get('type') != 'msg': + continue + if last_in is None and item['dir'] == 'in': + last_in = item['text'] + if last_out is None and item['dir'] == 'out': + last_out = item['text'] + if last_in and last_out: + break + + # 2. 有一条为空就重试 + if not last_in or not last_out: + attempt += 1 + if attempt == 3: + break # 三次用完,放弃 + time.sleep(0.2) + xml = session.source() + msgs = AiUtils.extract_messages_from_xml(xml) + continue + else: + break # 至少一条有内容,成功退出 + + LogManager.method_info(f"检测到对方最后发送的消息:{last_in}", "检测消息", udid) + LogManager.method_info(f"检测我发送的最后一条信息:{last_out}", "检测消息", udid) + + # 获取主播的名称 + # anchor_name = AiUtils.get_navbar_anchor_name(session) + anchor_name = "" + for _ in range(3): # 最多 2 次重试 + 1 次初始 + anchor_name = AiUtils.get_navbar_anchor_name(session) + if anchor_name: + break + time.sleep(1) + + LogManager.method_info(f"获取主播的名称:{anchor_name}", "检测消息", udid) + LogManager.method_info(f"获取主播最后发送的消息 即将翻译:{last_in}", "检测消息", udid) + + chinese_last_msg_text = "" + if last_in is not None: + for attempt in range(3): + chinese_last_msg_text = Requester.translationToChinese(last_in) + if chinese_last_msg_text: # 非空则跳出循环 + break + else: + chinese_last_msg_text = "" + + # 进行判断,判断翻译后是否 + + LogManager.method_info(f"翻译中文后的内容,交给前端进行展示:{chinese_last_msg_text}", "检测消息", + udid) + + # 找到输入框 + last_data = [{ + "sender": anchor_name, + "device": udid, + "time": datetime.now().strftime("%Y-%m-%d %H:%M"), + "text": chinese_last_msg_text, + "status": 0 + }] + + LogManager.method_info(f"主播最后发送的数据,传递给前端进行记录:{chinese_last_msg_text}", + "检测消息", udid) + + if last_data != "" and last_data != "消息请求": + # 把主播的名称存储到c盘 + JsonUtils.append_json_items(last_data, "log/last_message.json") + + # 从C盘中读取数据 + anchorWithSession = IOSAIStorage.load() + + sel = session.xpath("//TextView") + if anchor_name not in anchorWithSession: + + # 如果是第一次发消息(没有sessionId的情况) + LogManager.method_info(f"第一次发消息:{anchor_name},没有记忆 开始请求ai", "检测消息", udid) + LogManager.method_info(f"向ai发送的参数: 文本为:{last_in}", "检测消息", udid) + + if last_in is None: + LogManager.method_info(f"检测不到对方发送的最后一条消息,发送一条打招呼", "检测消息", + udid) + + text = "ok" + if last_out and last_out != "已看过": + text = last_out + + if sel.exists: + sel.click() # 聚焦 + event.wait(timeout=1) + sel.clear_text() + + LogManager.method_info( + f"发送的消息,检测不到对方发送的消息,不走ai:{text or '暂无数据'}", "检测消息", + udid) + + sel.set_text(f"{text or '暂无数据'}\n") + else: + LogManager.method_error("找不到输入框,重启", "检测消息", udid) + raise Exception("找不到输入框,重启") + else: + aiResult, sessionId = Requester.chatToAi({"query": last_in, "user": "1"}) + + if anchor_name and anchor_name != "消息请求": + IOSAIStorage.save({anchor_name: sessionId}, mode="merge") + else: + LogManager.method_warning(f"跳过保存 sessionId,anchor_name 不合法: '{anchor_name}'", + "检测消息", udid) + + # 找到输入框,输入ai返回出来的消息 + + if sel.exists: + sel.click() # 聚焦 + event.wait(timeout=1) + sel.clear_text() + LogManager.method_info( + f"发送的消息,检测到对方发送的消息,进行走ai(没记忆):{aiResult or '暂无数据'}", + "检测消息", + udid) + sel.set_text(f"{aiResult or '暂无数据'}\n") + else: + LogManager.method_error("找不到输入框,重启", "检测消息", udid) + raise Exception("找不到输入框,重启") + else: + print("有记忆") + + LogManager.method_info(f"不是一次发消息:{anchor_name},有记忆", "检测消息", udid) + # 如果不是第一次发消息(证明存储的有sessionId) + + if anchor_name and anchor_name != "消息请求": + + sessionId = anchorWithSession[anchor_name] + + if last_in is None: + last_in = "ok" + + if sel.exists: + sel.click() # 聚焦 + event.wait(timeout=1) + sel.clear_text() + LogManager.method_info( + f"发送的消息,检测到对方发送的消息,进行走ai(有记忆):{last_in or '暂无数据'}", + "检测消息", + udid) + sel.set_text(f"{last_in or '暂无数据'}\n") + else: + + # TODO: user后续添加,暂时写死 + + LogManager.method_info(f"向ai发送的参数: 文本为:{last_in}", "检测消息", udid) + + aiResult, sessionId = Requester.chatToAi( + {"query": last_in, "conversation_id": sessionId, "user": "1"}) + + if sel.exists: + sel.click() # 聚焦 + event.wait(timeout=1) + sel.clear_text() + LogManager.method_info( + f"发送的消息,检测到对方发送的消息,进行走ai(有记忆):{aiResult or '暂无数据'}", + "检测消息", + udid) + sel.set_text(f"{aiResult or '暂无数据'}\n") + + LogManager.method_info(f"存储的sessionId:{anchorWithSession}", "检测消息", udid) + event.wait(timeout=1) + + # 返回 + ControlUtils.clickBack(session) + + # 重新回到收件箱页面后,强制刷新节点 + session.appium_settings({"snapshotMaxDepth": 25}) + event.wait(timeout=1) + + try: + # 如果 2 秒内找不到,会抛异常 + badge_text = session.xpath(xp_new_fan_badge).get(timeout=2.0) + val = (badge_text.info.get("value") or + badge_text.info.get("label") or + badge_text.info.get("name")) + + LogManager.method_info(f"新粉丝未读数量:{val}", "检测消息", udid) + if badge_text: + badge_text.tap() + event.wait(timeout=1) + ControlUtils.clickBack(session) + event.wait(timeout=1) + except Exception: + LogManager.method_warning("当前屏幕没有找到 新粉丝 未读徽标数字", "检测消息", udid) + print("当前屏幕没有找到 新粉丝 未读徽标数字", udid) + badge_text = None + info_count += 1 + + try: + # 如果 2 秒内找不到,会抛异常 + badge_text = session.xpath(xp_activity_badge).get(timeout=2.0) + val = (badge_text.info.get("value") or + badge_text.info.get("label") or + badge_text.info.get("name")) + LogManager.method_info(f"活动未读数量:{val}", "检测消息", udid) + if badge_text: + badge_text.tap() + event.wait(timeout=1) + ControlUtils.clickBack(session) + event.wait(timeout=1) + except Exception: + LogManager.method_warning("当前屏幕没有找到 活动 未读徽标数字", "检测消息", udid) + print("当前屏幕没有找到 活动 未读徽标数字", udid) + badge_text = None + info_count += 1 + + try: + # 如果 2 秒内找不到,会抛异常 + badge_text = session.xpath(xp_system_badge).get(timeout=2.0) + val = (badge_text.info.get("value") or + badge_text.info.get("label") or + badge_text.info.get("name")) + LogManager.method_info(f"系统通知未读数量:{val}", "检测消息", udid) + if badge_text: + badge_text.tap() + event.wait(timeout=1) + ControlUtils.clickBack(session) + event.wait(timeout=1) + except Exception: + LogManager.method_warning("当前屏幕没有找到 系统通知 未读徽标数字", "检测消息", udid) + print("当前屏幕没有找到 系统通知 未读徽标数字", udid) + badge_text = None + info_count += 1 + + try: + # 如果 2 秒内找不到,会抛异常 + badge_text = session.xpath(xp_request_badge).get(timeout=2.0) + val = (badge_text.info.get("value") or + badge_text.info.get("label") or + badge_text.info.get("name")) + LogManager.method_info(f"消息请求未读数量:{val}", "检测消息", udid) + if badge_text: + badge_text.tap() + event.wait(timeout=1) + ControlUtils.clickBack(session) + event.wait(timeout=1) + except Exception: + LogManager.method_warning("当前屏幕没有找到 消息请求 未读徽标数字", "检测消息", udid) + print("当前屏幕没有找到 消息请求 未读徽标数字", udid) + badge_text = None + info_count += 1 + + # 双击收件箱 定位到消息的位置 + if info_count == 5: + r = el.bounds # 可能是命名属性,也可能是 tuple + cx = int((r.x + r.width / 2) if hasattr(r, "x") else (r[0] + r[2] / 2)) + cy = int((r.y + r.height / 2) if hasattr(r, "y") else (r[1] + r[3] / 2)) + session.double_tap(cx, cy) # 可能抛异常:方法不存在 + LogManager.method_info(f"双击收件箱 定位到信息", "检测消息", udid) + else: + + return + else: + LogManager.method_error(f"检测不到收件箱", "检测消息", udid) + raise Exception("当前页面找不到收件箱,重启") + + # 放在 ScriptManager 类外面或 utils 里 + def interruptible_sleep(self, event: threading.Event, seconds: float, slice_: float = 1.0): + """把一次长 sleep 拆成 1 秒一片,随时响应 event""" + left = seconds + while left > 0 and not event.is_set(): + timeout = min(slice_, left) + event.wait(timeout=timeout) + left -= timeout + return not event.is_set() # 返回 True 表示正常睡完,False 被中断 + + # 切换账号工具方法 + def _norm_txt(self, s: str) -> str: + return (s or "").strip() + + def _dedup_preserve_order(self, seq): + seen = set() + out = [] + for x in seq: + if x not in seen: + out.append(x) + seen.add(x) + return out + + # 切换账号 + def changeAccount(self, udid): + + client = wda.USBClient(udid, ev.wdaFunctionPort) + session = client.session() + width, height, scale = self.get_screen_info(udid) + count = 0 + while count <= 5: + try: + + + # 重启打开 + ControlUtils.closeTikTok(session, udid) + time.sleep(1) + ControlUtils.openTikTok(session, udid) + + LogManager.method_info("开始进行切换账号", "切换账号", udid) + LogManager.method_info("重启进行切换账号", "切换账号", udid) + + # + + # 打开个人主页 + session.appium_settings({"snapshotMaxDepth": 15}) + user_home = session.xpath('//XCUIElementTypeButton[@name="a11y_vo_profile" or @label="主页"]') + LogManager.method_info("检测主页按钮", "切换账号", udid) + if user_home.exists: + user_home.click() + LogManager.method_info("进入主页成功", "切换账号", udid) + else: + LogManager.method_info("未检测到主页按钮,后续流程可能失败", "切换账号", udid) + count += 1 + continue + + # 点击“切换账号”按钮(能点就点;点不到再走 OCR) + session.appium_settings({"snapshotMaxDepth": 25}) + switch_btn = session.xpath( + '//XCUIElementTypeButton[' + '@name="Switch accounts" or @label="Switch accounts" or ' + '@name="切换账号" or @label="切换账号" or ' + '@name="切換帳號" or @label="切換帳號"' + ']' + ) + if switch_btn.exists: + switch_btn.click() + LogManager.method_info("已点击“切换账号”", "切换账号", udid) + else: + LogManager.method_info("未检测到“切换账号”按钮,转入 OCR 兜底", "切换账号", udid) + + # 截图 → OCR + try: + img = client.screenshot() + base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + res_dir = os.path.join(base_dir, "resources", udid) + os.makedirs(res_dir, exist_ok=True) + file_path = os.path.join(res_dir, "account_list.png") + img.save(file_path) + time.sleep(1) + LogManager.method_info(f"保存屏幕图像成功 -> {file_path}", "切换账号", udid) + except Exception as e: + LogManager.method_info(f"截图或保存失败, 原因:{e}", "切换账号", udid) + count += 1 + continue + + image_path = AiUtils.imagePathWithName(udid, "account_list") + ocr_json = TencentOCR.recognize(image_path=image_path, action="GeneralBasicOCR") + + LogManager.method_info(f"OCR 结果: {ocr_json}", "切换账号", udid) + + # 提取“切换账号”与“添加账号”之间的用户名候选(只看像用户名的) + items = TencentOCR.slice_texts_between(ocr_json, "切换账号", "添加账号", username_like=True) + + # 稳定排序(Y 再 X) + items_sorted = sorted( + items, + key=lambda d: (d.get("ItemPolygon", {}).get("Y", 0), + d.get("ItemPolygon", {}).get("X", 0)) + ) + + # 规范化 & 去重保序 + def _norm_txt(s: str) -> str: + return (s or "").strip().lstrip("@") + + def _dedup(seq): + seen, out = set(), [] + for x in seq: + if x and x not in seen: + seen.add(x) + out.append(x) + return out + + usernames_from_ocr = _dedup([_norm_txt(d.get("DetectedText", "")) for d in items_sorted]) + LogManager.method_info(f"OCR 提取候选账号(排序后): {usernames_from_ocr}", "切换账号", udid) + + if not usernames_from_ocr: + LogManager.method_info("OCR 未发现任何账号,无法切换", "切换账号", udid) + count += 1 + continue + + # —— 读取与更新轮询状态 —— # + state_path = f"{udid}/accountState.json" + state = IOSAIStorage.load(state_path) or {"accounts": [], "idx": 0} + old_accounts = state.get("accounts") or [] + + # 只有“集合真的变化”才更新 accounts;否则保持旧顺序不动,保证轮询不抖动 + if set(old_accounts) != set(usernames_from_ocr): + merged = [a for a in old_accounts if a in usernames_from_ocr] + \ + [u for u in usernames_from_ocr if u not in old_accounts] + state["accounts"] = merged + state["idx"] = 0 # 集合变化才重置 + LogManager.method_info(f"账号集合变化,合并后顺序: {merged},重置 idx=0", "切换账号", udid) + else: + if not old_accounts: + state["accounts"] = usernames_from_ocr + state["idx"] = 0 + + accounts = state["accounts"] + if not accounts: + LogManager.method_info("账号列表为空", "切换账号", udid) + count += 1 + continue + + # —— 核心轮询:1→2→0→1→2→… —— # + n = len(accounts) + try: + idx = int(state.get("idx", 0)) % n + except Exception: + idx = 0 + next_idx = (idx + 1) % n # 本次选择“下一位” + target_account = accounts[next_idx] + + # 立刻推进并落盘:保存为 next_idx,下一次会从它的下一位继续 + state["idx"] = next_idx + IOSAIStorage.save(state, state_path) + + LogManager.method_info( + f"本次切换到账号: {target_account} (use={next_idx}, next={(next_idx + 1) % n})", + "切换账号", + udid + ) + print(f"本次切换到账号: {target_account} (use={next_idx}, next={(next_idx + 1) % n})", "切换账号", udid) + + # 在同一份 OCR 结果里定位该用户名的坐标并点击 + result = TencentOCR.find_last_name_bbox(ocr_json, target_account) + if not result: + LogManager.method_info(f"OCR 未找到目标账号文本: {target_account}", "切换账号", udid) + count += 1 + continue + + center_x = result["center"]["x"] + center_y = result["center"]["y"] + + # 随机偏移(增强拟人) + num = random.randint(-10, 10) + # 分辨率/坐标映射(按你设备比例;你原来是 /3) + if scale == 3.0: + tap_x = int((center_x + num) / 3) + tap_y = int((center_y + num) / 3) + else: + tap_x = int((center_x + num) / 2) + tap_y = int((center_y + num) / 2) + + LogManager.method_info(f"点击坐标: ({tap_x}, {tap_y}),账号: {target_account}", "切换账号", udid) + session.tap(tap_x, tap_y) + + time.sleep(5) + + return 200, "成功" + + except Exception as e: + LogManager.method_error(f"切换账号失败, 错误: {e}", "切换账号", udid) + count += 1 + return 500, "失败" + + def test(self): + # 找到输入框 + anchor_name = "sss" + udid = "sss" + last_data = [{ + "sender": anchor_name, + "device": udid, + "time": datetime.now().strftime("%Y-%m-%d %H:%M"), + "text": "哈哈哈", + "status": 0 + }] + print(last_data) + + JsonUtils.append_json_items(last_data, "log/last_message.json") diff --git a/tidevice_entry.py b/tidevice_entry.py new file mode 100644 index 0000000..75508ed --- /dev/null +++ b/tidevice_entry.py @@ -0,0 +1,37 @@ +# import sys, traceback, os +# from tidevice.__main__ import main +# +# import sys, os +# with open(os.path.join(os.path.dirname(sys.executable), '_entry_log.txt'), 'w') as f: +# f.write('entry reached\nargs=%r\n' % sys.argv) +# +# if hasattr(sys, 'frozen') and sys.executable.endswith('.exe'): +# # 打包后且无控制台时,把标准流扔掉 +# sys.stdout = sys.stderr = open(os.devnull, 'w', encoding='utf-8') +# +# if __name__ == "__main__": +# try: +# main() +# except Exception: +# # 把 traceback 写到日志文件,但**不输出到控制台** +# with open(os.path.expanduser("~/tidevice_crash.log"), "a", encoding="utf-8") as f: +# traceback.print_exc(file=f) +# # 静默退出,返回码 1 +# sys.exit(1) + + +import sys, traceback, os +from tidevice.__main__ import main + +if __name__ == "__main__": + try: + main() + except SystemExit as se: # 允许正常 exit(code) 继续生效 + raise + except Exception: # 真正异常时才写日志 + crash_log = os.path.expanduser("~/tidevice_crash.log") + with open(crash_log, "a", encoding="utf-8") as f: + f.write("----- tidevice exe crash -----\n") + traceback.print_exc(file=f) + # 如果想让用户知道崩溃了,可以返回非 0 + sys.exit(1) \ No newline at end of file