From bce48fc13764ced8f4bba273ebd0bccec22c5ba3 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Fri, 7 Aug 2026 13:55:48 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=88=9D=E5=A7=8B=E5=8C=96=E4=BB=93?= =?UTF-8?q?=E5=BA=93=EF=BC=88=E6=8E=92=E9=99=A4=20adb=20=E4=BA=8C=E8=BF=9B?= =?UTF-8?q?=E5=88=B6=E6=96=87=E4=BB=B6=E4=BB=A5=E8=A7=A3=E5=86=B3=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E8=B6=85=E6=97=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 35 + config.py | 37 + core/__init__.py | 8 + core/actions/__init__.py | 27 + core/actions/base.py | 120 ++++ core/adb_helper.py | 154 +++++ core/apk_manager.py | 389 +++++++++++ core/device_worker.py | 433 ++++++++++++ core/logger.py | 84 +++ core/models.py | 242 +++++++ core/stf_client.py | 210 ++++++ core/task_manager.py | 734 ++++++++++++++++++++ core/u2_helper.py | 114 +++ create_task.py | 85 +++ data/users.db | Bin 0 -> 36864 bytes doc/TASK_DEV.md | 1045 ++++++++++++++++++++++++++++ main.py | 43 ++ requirements.txt | 25 + scripts/__init__.py | 1 + scripts/cleanup.py | 49 ++ scripts/fix_web.bat | 57 ++ scripts/pack.py | 67 ++ start_web.bat | 34 + static/admin/custom.css | 55 ++ tasks/__init__.py | 12 + tasks/base.py | 120 ++++ tasks/douyin/__init__.py | 9 + tasks/douyin/actions/__init__.py | 21 + tasks/douyin/actions/base.py | 51 ++ tasks/douyin/actions/like.py | 105 +++ tasks/douyin/task.py | 269 ++++++++ templates/admin/login.html | 42 ++ templates/admin/monitor.html | 1104 ++++++++++++++++++++++++++++++ web/index.html | 94 +++ web_server.py | 426 ++++++++++++ 35 files changed, 6301 insertions(+) create mode 100644 .gitignore create mode 100644 config.py create mode 100644 core/__init__.py create mode 100644 core/actions/__init__.py create mode 100644 core/actions/base.py create mode 100644 core/adb_helper.py create mode 100644 core/apk_manager.py create mode 100644 core/device_worker.py create mode 100644 core/logger.py create mode 100644 core/models.py create mode 100644 core/stf_client.py create mode 100644 core/task_manager.py create mode 100644 core/u2_helper.py create mode 100644 create_task.py create mode 100644 data/users.db create mode 100644 doc/TASK_DEV.md create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 scripts/__init__.py create mode 100644 scripts/cleanup.py create mode 100644 scripts/fix_web.bat create mode 100644 scripts/pack.py create mode 100644 start_web.bat create mode 100644 static/admin/custom.css create mode 100644 tasks/__init__.py create mode 100644 tasks/base.py create mode 100644 tasks/douyin/__init__.py create mode 100644 tasks/douyin/actions/__init__.py create mode 100644 tasks/douyin/actions/base.py create mode 100644 tasks/douyin/actions/like.py create mode 100644 tasks/douyin/task.py create mode 100644 templates/admin/login.html create mode 100644 templates/admin/monitor.html create mode 100644 web/index.html create mode 100644 web_server.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c5a225 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +.venv/ +env/ + +# 运行时数据 +logs/*.log +data/apks/*.apk +data/*.migrated + +# 数据库(含用户和任务数据,按需决定是否提交) + + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# 临时文件 +*.tmp +*.bak + + +bin/adb/* \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..4d87155 --- /dev/null +++ b/config.py @@ -0,0 +1,37 @@ +"""项目根配置:只放核心程序级配置。 + +任务自身的参数(观看时长、点赞开关、滑动速度等)不在这里, +放各自 tasks/xxx.py 顶部,保持每个任务自包含、互不干扰。 + +改 STF 地址/token、adb 路径、web 端口都改这里。 +""" +import os + +# ================== STF 配置 ================== +STF_URL = "http://192.168.20.220:7100" +STF_TOKEN = "9024544b571647d3a6b09dab5dbfcb28bfd950f5b659400394e44e7217b7051b" + +# ================== adb 路径 ================== +# adb 二进制及依赖统一放在 bin/adb/ 下 +# Windows: adb.exe(依赖同目录的 dll) +# Linux: adb(需 chmod +x,Linux 版二进制) +# 代码自动识别操作系统,用户只需把对应平台的 adb 放进 bin/adb/ +import sys as _sys +_ADB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bin", "adb") +if _sys.platform == "win32": + ADB_PATH = os.path.join(_ADB_DIR, "adb.exe") +else: + # Linux / macOS:用 bin/adb/adb(无 .exe 后缀) + # 部署后需执行 chmod +x bin/adb/adb + ADB_PATH = os.path.join(_ADB_DIR, "adb") + +# ================== Web 后台 ================== +WEB_HOST = "0.0.0.0" +WEB_PORT = 5050 + +# ================== 数据目录 ================== +# 持久化数据(分组、任务计划)存放目录 +DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") + +# APK 文件存储目录(应用管理功能) +APK_DIR = os.path.join(DATA_DIR, "apks") diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..4fd3c32 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,8 @@ +"""core 包:基础设施层。 + + config — 全局配置常量(STF、adb 路径、养号参数) + adb_helper — adb 命令封装(并发安全) + stf_client — OpenSTF REST API 客户端 + device_worker — 设备生命周期 + 养号 worker + 全局状态注册表 + task_manager — 任务管理框架(分组/计划/调度/重试/持久化) +""" diff --git a/core/actions/__init__.py b/core/actions/__init__.py new file mode 100644 index 0000000..d19eeff --- /dev/null +++ b/core/actions/__init__.py @@ -0,0 +1,27 @@ +"""全局操作框架:所有 app 任务共用的 Action 基类与注册机制。 + +为什么放这里: + 抖音的点赞/评论 xpath 只适用抖音,留在 tasks/douyin/actions/。 + 但"操作"的抽象(BaseAction 接口、概率触发、注册机制)是通用的, + 放这里让所有 app 任务共享同一套操作开发范式。 + +加新 app 的专属操作: + 在 tasks//actions/ 下建 .py,继承本模块的 BaseAction, + 用 @register_action 装饰(注册到该 app 自己的注册表,见下方)。 + +加跨 app 通用操作(如"滑动到下一个"、"等待元素出现"): + 在 core/actions/ 下建 .py,继承 BaseAction。 +""" +from .base import ( + BaseAction, ActionContext, + register_action, create_action_registry, + list_actions, get_action, + should_trigger, +) + +__all__ = [ + "BaseAction", "ActionContext", + "register_action", "create_action_registry", + "list_actions", "get_action", + "should_trigger", +] diff --git a/core/actions/base.py b/core/actions/base.py new file mode 100644 index 0000000..fb1e83c --- /dev/null +++ b/core/actions/base.py @@ -0,0 +1,120 @@ +"""Action 基类与注册机制(全局通用,所有 app 任务共享接口)。 + +设计要点: + 1. BaseAction 定义统一接口:execute(d, params, worker) -> bool + 2. 每个 app 任务有自己的注册表(create_action_registry), + 互不污染——抖音的 like 和快手的 like 各注册各的。 + 3. 概率触发、状态上报等通用逻辑放这里,子类不重复写。 + 4. ActionContext 封装执行上下文,方便扩展(传 d/worker/进度/计数器)。 + +新增 app 专属操作步骤(照着 tasks/douyin/actions/like.py 抄): + + 1. 在 tasks//actions/ 下新建 my_action.py + 2. 写一个 BaseAction 子类,用 @register_action 装饰,实现 execute: + + from core.actions import BaseAction, register_action, should_trigger + from . import ACTIONS # 本 app 的注册表 + + @register_action(ACTIONS) + class MyAction(BaseAction): + action_type = "my_action" + name = "我的操作" + description = "做什么" + default_params = {"rate": 0.5} + + def execute(self, d, params, worker): + if not should_trigger(params["rate"]): + return False + # ... 用 d(xpath=...) / d(text=...) 操作 + return True + + 3. 在 tasks//actions/__init__.py 加:from . import my_action + + 做完前端自动出现该操作的可勾选项。 +""" +import random + +from core.logger import get_logger + +_log = get_logger("action") + + +class ActionContext: + """操作执行上下文,封装给 Action 用的所有依赖。 + + 现在只是简单封装,后续可扩展(如传任务参数、设备信息、进度回调)。 + Action 子类通过 worker 间接访问,或直接用 execute 的 d 参数。 + """ + + def __init__(self, worker=None, params=None): + self.worker = worker + self.params = params or {} + + +class BaseAction: + """所有操作的基类。子类实现 execute。 + + 约定: + execute 返回 True = 执行成功(会计入 action_counts) + execute 返回 False = 跳过/失败(不影响主流程,不抛异常) + execute 内部异常由调用方捕获,不影响其他操作和主循环 + """ + action_type = "base" + name = "基础操作" + description = "" + default_params = {} + + def execute(self, d, params, worker): + """执行操作。 + + d: u2.Device — 已连好的设备 + params: 本操作的参数 dict(已合并默认值) + worker: Worker 实例,可调 worker.set_action() 上报状态 + 返回 True=执行成功,False=跳过/失败。 + """ + raise NotImplementedError + + def __repr__(self): + return f"<{self.__class__.__name__}({self.action_type})>" + + +def should_trigger(rate): + """按概率决定是否触发(rate 是 0~1 的概率)。""" + return random.random() < rate + + +def register_action(registry): + """装饰器:把 Action 类注册到指定注册表。 + + 用法: + ACTIONS = create_action_registry() + @register_action(ACTIONS) + class MyAction(BaseAction): ... + + 每个 app 任务有自己的 ACTIONS 注册表,互不污染。 + """ + def _decorator(action_cls): + registry[action_cls.action_type] = action_cls + _log.debug(f"注册操作: {action_cls.action_type} -> {action_cls.__name__}") + return action_cls + return _decorator + + +def create_action_registry(): + """创建一个独立的操作注册表(每个 app 任务一个)。 + + 返回 dict: action_type -> ActionClass + """ + return {} + + +def list_actions(registry): + """返回注册表中所有操作的元信息(供前端展示)。""" + return [{"action_type": a.action_type, "name": a.name, + "description": a.description, + "default_params": a.default_params} for a in registry.values()] + + +def get_action(registry, action_type): + """按 action_type 取操作类。""" + return registry.get(action_type) diff --git a/core/adb_helper.py b/core/adb_helper.py new file mode 100644 index 0000000..301e9b2 --- /dev/null +++ b/core/adb_helper.py @@ -0,0 +1,154 @@ +"""adb 命令工具封装。 + +并发关键:adb_connect 绝不 kill-server(会断开其他 worker 正在用的设备), +用全局锁串行化连接阶段,connect 本身很快,串行不影响整体并发。 +""" +import re +import time +import subprocess +import threading + +from config import ADB_PATH +from core.logger import get_logger + +_log = get_logger("core.adb") + +_ADB_LOCK = threading.Lock() + + +def _adb(*args): + """执行 adb 命令,返回 (stdout+stderr) 文本。不用 shell=True,避免子进程环境问题。 + + 用 bytes 模式自己解码(Windows 下 subprocess text=True 会用 GBK 读 adb 输出, + 遇到非 GBK 字节会崩溃)。 + """ + r = subprocess.run([ADB_PATH, *args], capture_output=True) + out = (r.stdout or b"") + (r.stderr or b"") + try: + return out.decode("utf-8", errors="replace").strip() + except Exception: + return out.decode("gbk", errors="replace").strip() + + +def get_foreground_app(remote_adb_url, timeout=3): + """获取设备当前前台 App 包名。 + + 通过 adb shell dumpsys window 解析 mCurrentFocus。 + remote_adb_url: adb 连接地址(如 192.168.20.17:12345),设备需已 adb connect。 + 返回包名字符串(如 com.ss.android.ugc.aweme),失败/无设备返回 None。 + """ + if not remote_adb_url: + return None + try: + r = subprocess.run( + [ADB_PATH, "-s", remote_adb_url, "shell", + "dumpsys window | grep mCurrentFocus"], + capture_output=True, timeout=timeout + ) + out = (r.stdout or b"").decode("utf-8", errors="replace").strip() + if not out: + return None + low = out.lower() + if "device offline" in low or "device not found" in low or "error:" in low: + return None + # 解析 mCurrentFocus=Window{abc u0 com.xx/com.xx.MainActivity} + for line in out.split("\n"): + if "mCurrentFocus" not in line: + continue + # 提取 u0 后面的 token(包名/Activity 或包名) + m = re.search(r'\bu0\s+(\S+)', line) + if m: + token = m.group(1).rstrip("}") + if "/" in token: + return token.split("/")[0] + return token or None + return None + except subprocess.TimeoutExpired: + return None + except Exception: + return None + + +def adb_connect(remote_adb_url, retries=5): + """adb connect(带重试)。返回 True/False。 + + 首次常因认证握手未就绪报 failed to authenticate,第2次重试通常 already connected。 + STF remoteConnect 启用后端口监听可能需要几秒,所以重试间隔递增。 + 若全部重试失败,做一次 kill-server 硬重置后再试(long-running 进程的 adb daemon + 可能状态坏掉,必须重置)。硬重置在全局锁内,会短暂影响并发 worker,但只在万不得已时触发。 + """ + with _ADB_LOCK: + if _try_connect(remote_adb_url, retries): + return True + # 全部失败,硬重置 adb server + _log.warning(f"{remote_adb_url} 重试耗尽,执行 kill-server 硬重置") + _adb("kill-server") + time.sleep(2) + _adb("start-server") + time.sleep(2) + return _try_connect(remote_adb_url, 3) + return False + + +def _try_connect(remote_adb_url, retries): + """尝试连接,不 kill-server。返回 True/False。""" + for attempt in range(1, retries + 1): + out = _adb("connect", remote_adb_url) + _log.info(f"{remote_adb_url} 第 {attempt} 次尝试: {out}") + if "connected" in out.lower() and "failed" not in out.lower(): + return True + time.sleep(1 + attempt) + return False + + +def adb_connect_light(remote_adb_url): + """轻量 adb connect(扫描专用):单次尝试,不重试,绝不 kill-server。 + + 与 adb_connect 的区别: + - 不重试:失败立即返回 False + - 不 kill-server:避免断开其他 worker 正在使用的 ADB 连接 + 用于前台 App 扫描等非关键路径,连不上就跳过,不影响正在运行的任务。 + """ + with _ADB_LOCK: + out = _adb("connect", remote_adb_url) + if "connected" in out.lower() and "failed" not in out.lower(): + return True + return False + + +def adb_disconnect(remote_adb_url): + """adb disconnect。""" + try: + _adb("disconnect", remote_adb_url) + except Exception: + pass + + +def identify_device(serial, duration=3): + """让设备响铃以供识别(直连 serial,不经过 STF)。 + + 播放铃声 duration 秒后自动停止。 + 不 disconnect(避免断开 STF agent 共享的 adb 连接)。 + 返回 (ok, msg)。 + """ + import subprocess + if not adb_connect_light(serial): + return False, "adb connect 失败" + try: + # 异步启动铃声播放(cmd audio play-ringtone 会阻塞,用 Popen) + p = subprocess.Popen( + [ADB_PATH, "-s", serial, "shell", "cmd", "audio", "play-ringtone"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + time.sleep(duration) + p.terminate() + # 停止铃声 + subprocess.run( + [ADB_PATH, "-s", serial, "shell", "cmd", "audio", "stop-ringtone"], + capture_output=True, timeout=5, + ) + _log.info(f"[{serial}] 识别响铃 {duration}s 完成") + return True, f"已响铃 {duration} 秒" + except Exception as e: + _log.warning(f"[{serial}] 识别响铃失败: {e}") + return False, str(e) diff --git a/core/apk_manager.py b/core/apk_manager.py new file mode 100644 index 0000000..884d279 --- /dev/null +++ b/core/apk_manager.py @@ -0,0 +1,389 @@ +"""APK 文件管理 + 批量安装。 + +功能: + - upload: 保存 APK 到 data/apks/,用 pyaxmlparser 解析包名/版本/应用名,入库 + - list_all: 列出所有已上传的 APK + - delete: 删文件 + 删数据库记录 + - install: 批量安装到指定设备(后台线程,直连设备 adb) + - get_install_status: 获取安装进度 + +设备连接策略(直连设备 IP:5555,完全绕过 STF occupy/release): + - worker 运行中:跳过(避免打断任务) + - 其他设备(自己占用/空闲/他人占用):直接 adb connect serial → install → disconnect + 不经过 STF,不会触发 STF agent 清理,安装的 app 会永久保留 + - serial 格式为 IP:5555(设备本身的 adb 网络地址),STF 返回的 serial 即此格式 + +为什么不用 STF occupy/release: + STF release 会触发 agent 清理设备(卸载第三方 app、清除数据、回桌面), + 导致刚安装的 app 被自动删除。直连设备完全绕过 STF,安装完断开连接即可。 +""" +import os +import time +import uuid +import threading +import subprocess +from concurrent.futures import ThreadPoolExecutor, as_completed + +from config import APK_DIR, ADB_PATH +from core.logger import get_logger +from core.models import db, ApkFile as ApkRow +from .stf_client import STFClient +from .adb_helper import adb_disconnect +from .device_worker import get_all_worker_status + +_log = get_logger("core.apk") + +# adb install 超时(秒)。大 APK 安装慢,给 5 分钟 +_INSTALL_TIMEOUT = 300 +# 并发安装数 +_INSTALL_CONCURRENCY = 5 + + +class ApkManager: + """APK 文件管理 + 批量安装。""" + + def __init__(self, stf_client=None, app=None): + self.stf = stf_client or STFClient() + self.app = app # Flask app,用于 db context + os.makedirs(APK_DIR, exist_ok=True) + self._lock = threading.Lock() + self._install_task = None # 当前安装任务 + + def _db(self): + if self.app is None: + raise RuntimeError("ApkManager 未关联 Flask app") + return self.app.app_context() + + # ================== 上传 ================== + def upload(self, file_storage): + """保存上传的 APK 文件,解析元信息,入库。 + + file_storage: werkzeug FileStorage 对象 + 返回: dict (APK 信息) 或 None(失败) + """ + # 生成唯一 id + apk_id = uuid.uuid4().hex[:8] + original_name = file_storage.filename or f"{apk_id}.apk" + if not original_name.lower().endswith(".apk"): + original_name += ".apk" + disk_filename = f"{apk_id}.apk" + disk_path = os.path.join(APK_DIR, disk_filename) + + # 保存文件 + try: + file_storage.save(disk_path) + except Exception as e: + _log.error(f"保存 APK 文件失败: {e}") + return None + + size = os.path.getsize(disk_path) + display_name = os.path.splitext(original_name)[0] + package_name = "" + version_name = "" + version_code = 0 + + # 解析 APK 元信息 + try: + from pyaxmlparser import APK as APKParser + apk = APKParser(disk_path) + package_name = apk.package or "" + version_name = getattr(apk, "version_name", "") or "" + version_code = getattr(apk, "version_code", 0) or 0 + # 应用名:优先 get_app_name(),其次 application.label + try: + display_name = apk.get_app_name() or display_name + except Exception: + try: + display_name = apk.application.label or display_name + except Exception: + pass + _log.info(f"解析 APK 成功: {display_name} ({package_name} v{version_name})") + except ImportError: + _log.warning("pyaxmlparser 未安装,跳过 APK 元信息解析") + except Exception as e: + _log.warning(f"解析 APK 元信息失败(不影响上传): {e}") + + # 入库 + upload_time = time.strftime("%Y-%m-%d %H:%M:%S") + try: + with self._db(): + row = ApkRow(id=apk_id, filename=disk_filename, + display_name=display_name, + package_name=package_name, + version_name=version_name, + version_code=version_code, + size=size, upload_time=upload_time) + db.session.add(row) + db.session.commit() + _log.info(f"APK 上传成功: {display_name}({apk_id}) {size}字节") + return row.to_dict() + except Exception as e: + _log.error(f"APK 入库失败: {e}") + # 入库失败但文件已存,仍然返回基本信息 + try: + os.remove(disk_path) + except Exception: + pass + return None + + # ================== 列表 ================== + def list_all(self): + """列出所有 APK。""" + try: + with self._db(): + rows = ApkRow.query.order_by(ApkRow.upload_time.desc()).all() + return [r.to_dict() for r in rows] + except Exception as e: + _log.error(f"列出 APK 失败: {e}") + return [] + + # ================== 删除 ================== + def delete(self, apk_id): + """删除 APK 文件和数据库记录。""" + try: + with self._db(): + row = ApkRow.query.get(apk_id) + if not row: + return False, "APK 不存在" + name = row.display_name or row.filename + # 删文件 + disk_path = os.path.join(APK_DIR, row.filename) + if os.path.exists(disk_path): + try: + os.remove(disk_path) + except Exception as e: + _log.warning(f"删除 APK 文件失败: {e}") + # 删记录 + db.session.delete(row) + db.session.commit() + _log.info(f"已删除 APK: {name}({apk_id})") + return True, f"已删除 {name}" + except Exception as e: + _log.error(f"删除 APK 失败: {e}") + return False, str(e) + + # ================== 批量安装 ================== + def install(self, apk_id, serials): + """批量安装 APK 到指定设备(后台线程执行)。 + + 参数: + apk_id: APK ID + serials: 设备 serial 列表 + + 返回: (ok, msg) + """ + with self._lock: + if self._install_task and not self._install_task.get("finished"): + return False, "已有安装任务正在进行,请等待完成" + + # 查 APK 信息 + try: + with self._db(): + row = ApkRow.query.get(apk_id) + if not row: + return False, "APK 不存在" + apk_path = os.path.join(APK_DIR, row.filename) + apk_name = row.display_name or row.filename + package_name = row.package_name or "" + if not os.path.exists(apk_path): + return False, "APK 文件不存在" + except Exception as e: + return False, str(e) + + if not serials: + return False, "未选择设备" + + # 获取设备信息(型号等) + try: + all_devices = {d["serial"]: d for d in self.stf.list_all_devices()} + except Exception: + all_devices = {} + + # 获取 worker 状态(判断设备是否运行中) + worker_status = {w["serial"]: w for w in get_all_worker_status()} + + # 构造安装任务 + items = {} + for s in serials: + dev = all_devices.get(s, {}) + model = dev.get("model") or dev.get("manufacturer") or s + items[s] = { + "name": model, + "status": "pending", + "msg": "", + } + + self._install_task = { + "apk_id": apk_id, + "apk_name": apk_name, + "started_at": time.time(), + "finished": False, + "total": len(serials), + "items": items, + } + + # 后台线程执行安装 + t = threading.Thread(target=self._install_worker, + args=(apk_id, apk_path, apk_name, package_name, + serials, all_devices, worker_status), + name="apk-install", daemon=True) + t.start() + _log.info(f"开始安装 {apk_name} 到 {len(serials)} 台设备") + return True, f"开始安装 {apk_name} 到 {len(serials)} 台设备" + + def _install_worker(self, apk_id, apk_path, apk_name, package_name, + serials, all_devices, worker_status): + """后台安装线程:直连设备安装,完全绕过 STF。""" + # 只跳过 worker 运行中的设备,其他设备(空闲/自己占用/他人占用)都直连安装 + install_list = [] + for s in serials: + w = worker_status.get(s, {}) + if w.get("status") in ("running", "connecting"): + self._set_item(s, "skipped", "任务运行中,已跳过") + continue + install_list.append(s) + + _log.info(f"安装任务 {apk_name}: 待安装={len(install_list)}, " + f"跳过={len(serials)-len(install_list)}") + + if install_list: + with ThreadPoolExecutor(max_workers=_INSTALL_CONCURRENCY) as pool: + futures = {pool.submit(self._install_one, s, apk_path, + package_name): s + for s in install_list} + for fut in as_completed(futures, timeout=_INSTALL_TIMEOUT + 60): + s = futures[fut] + try: + fut.result() + except Exception as e: + self._set_item(s, "failed", f"安装异常: {e}") + + # 标记完成 + if self._install_task: + self._install_task["finished"] = True + # 统计 + success = sum(1 for v in self._install_task["items"].values() + if v["status"] == "success") + failed = sum(1 for v in self._install_task["items"].values() + if v["status"] == "failed") + skipped = sum(1 for v in self._install_task["items"].values() + if v["status"] == "skipped") + _log.info(f"安装完成 {apk_name}: 成功={success}, 失败={failed}, 跳过={skipped}") + + def _install_one(self, serial, apk_path, package_name=""): + """直连设备安装 APK。 + + serial 即设备的 adb 网络地址(IP:5555),直接 adb connect 即可。 + 不经过 STF occupy/release,安装完断开连接,app 永久保留。 + """ + from .adb_helper import _adb + self._set_item(serial, "installing", f"正在连接 {serial}...") + try: + # adb connect(带重试,设备网络可能需要几次握手) + connected = False + last_out = "" + for attempt in range(1, 4): # 最多 3 次 + out = _adb("connect", serial) + last_out = out + if "connected" in out.lower() and "failed" not in out.lower(): + connected = True + break + _log.info(f"[{serial}] adb connect 第{attempt}次: {out}") + time.sleep(1 + attempt) + if not connected: + _log.warning(f"[{serial}] adb connect 失败: 输出={last_out}") + self._set_item(serial, "failed", f"adb connect 失败: {last_out[:150]}") + return + + # 安装 + self._set_item(serial, "installing", "正在安装...") + r = subprocess.run( + [ADB_PATH, "-s", serial, "install", "-r", apk_path], + capture_output=True, timeout=_INSTALL_TIMEOUT + ) + out = ((r.stdout or b"") + (r.stderr or b"")) + try: + out = out.decode("utf-8", errors="replace") + except Exception: + out = out.decode("gbk", errors="replace") + out = out.strip() + _log.info(f"[{serial}] adb install 返回码={r.returncode}, 输出={out[:300]}") + low = out.lower() + install_ok = False + if "success" in low: + install_ok = True + elif "already_installed" in low or "already installed" in low: + install_ok = True + + if not install_ok: + msg = out.replace("\n", " ").strip()[:200] + self._set_item(serial, "failed", msg or "安装失败(returncode=%d)" % r.returncode) + _log.warning(f"[{serial}] APK 安装失败: {msg}") + return + + # 安装后验证:查 pm list packages 确认包名是否真正存在 + if package_name: + self._set_item(serial, "installing", "正在验证安装结果...") + try: + rv = subprocess.run( + [ADB_PATH, "-s", serial, "shell", "pm", "list", "packages", package_name], + capture_output=True, timeout=30 + ) + vout = (rv.stdout or b"").decode("utf-8", errors="replace").strip() + _log.info(f"[{serial}] pm list packages {package_name}: {vout[:200]}") + if package_name in vout: + self._set_item(serial, "success", "安装成功(已验证)") + _log.info(f"[{serial}] APK 安装成功且已验证") + else: + self._set_item(serial, "failed", "adb返回Success但设备上未检测到包,可能被系统拦截") + _log.warning(f"[{serial}] 安装验证失败: adb返回Success但pm list未找到 {package_name}") + except Exception as e: + self._set_item(serial, "success", "安装成功(验证异常)") + _log.warning(f"[{serial}] 安装验证异常: {e}") + else: + self._set_item(serial, "success", "安装成功") + _log.info(f"[{serial}] APK 安装成功(无包名,跳过验证)") + except subprocess.TimeoutExpired: + self._set_item(serial, "failed", "安装超时") + except Exception as e: + self._set_item(serial, "failed", str(e)[:200]) + _log.exception(f"[{serial}] 安装异常") + finally: + # 断开 adb 连接(不影响设备,app 永久保留) + adb_disconnect(serial) + + def _set_item(self, serial, status, msg=""): + """更新安装任务中某设备的状态。""" + if not self._install_task: + return + items = self._install_task["items"] + if serial not in items: + items[serial] = {"name": serial, "status": status, "msg": msg} + else: + items[serial]["status"] = status + items[serial]["msg"] = msg + + def get_install_status(self): + """获取当前安装任务状态。""" + if not self._install_task: + return None + task = self._install_task + items = task["items"] + success = sum(1 for v in items.values() if v["status"] == "success") + failed = sum(1 for v in items.values() if v["status"] == "failed") + skipped = sum(1 for v in items.values() if v["status"] == "skipped") + installing = sum(1 for v in items.values() if v["status"] == "installing") + pending = sum(1 for v in items.values() if v["status"] == "pending") + return { + "apk_id": task["apk_id"], + "apk_name": task["apk_name"], + "started_at": task["started_at"], + "finished": task.get("finished", False), + "total": task["total"], + "success": success, + "failed": failed, + "skipped": skipped, + "installing": installing, + "pending": pending, + "items": dict(items), + } diff --git a/core/device_worker.py b/core/device_worker.py new file mode 100644 index 0000000..b83c1ad --- /dev/null +++ b/core/device_worker.py @@ -0,0 +1,433 @@ +"""设备生命周期 + 通用 Worker 基类 + 全局状态注册表 + 心跳看门狗。 + + STFDevice — 单设备生命周期:acquire(占用+adb连接)→ release(断开+释放) + BaseWorker — 通用 worker 基类:封装 STF 生命周期 + u2 连接 + 状态上报 + stop + + 生命周期钩子(setup/teardown/on_error) + 心跳上报 + 子类只需实现 run_task(d),可选实现钩子,不用管 STF/adb/状态/异常 + _WORKERS — 全局状态注册表,供 web_server 读取实时状态 + _watchdog — 心跳看门狗线程,检测 worker 卡死 + +新增任务只需写一个 BaseWorker 子类 + 一个 BaseTask 子类, +完全不用碰这里的代码。 +""" +import time +import threading +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout + +import uiautomator2 as u2 + +from core.logger import get_logger +from core.stf_client import DeviceOfflineError, STFError +from .adb_helper import adb_connect, adb_disconnect + +_log = get_logger("core.worker") + +# 心跳超时阈值(秒)。worker 超过这个时间没更新心跳,判定为卡死 +_HEARTBEAT_TIMEOUT = 120 +# 看门狗检查间隔 +_WATCHDOG_INTERVAL = 30 +# u2.connect / d.info 超时(秒)。超过则放弃,避免 worker 永久 hang +_U2_CONNECT_TIMEOUT = 30 +_U2_INFO_TIMEOUT = 10 + + +class STFDevice: + """单设备生命周期:acquire(占用+连接)→ release(断开+释放)。 + + 用 try/finally 保证设备一定被释放,即使养号中途报错。 + """ + + def __init__(self, stf_client, serial=None): + self.stf = stf_client + self.serial = serial + self.remote_adb_url = None + + def acquire(self, serial=None): + self.serial = serial or self.serial or self._pick_free() + _log.info(f"[{self.serial}] 选定设备") + + ok, msg = self.stf.occupy(self.serial) + if not ok: + raise STFError(f"占用设备失败: {msg}", "conflict") + _log.info(f"[{self.serial}] STF 占用成功") + + # 优先直连设备 serial(当 serial 本身就是 IP:5555 时,如 Tailscale 网络) + # 这比 STF remoteConnect 桥接更稳定,避免桥接地址认证失败 + if ":" in self.serial: + self.remote_adb_url = self.serial + _log.info(f"[{self.serial}] 直连设备: {self.remote_adb_url}") + else: + # serial 不是 IP:port 格式,走 STF remoteConnect 桥接 + self.remote_adb_url = self.stf.remote_connect(self.serial) + _log.info(f"[{self.serial}] STF 桥接: {self.remote_adb_url}") + + if not adb_connect(self.remote_adb_url): + raise STFError(f"adb connect 失败: {self.remote_adb_url}", "offline") + time.sleep(2) + + def _pick_free(self): + free = self.stf.list_free_devices() + if not free: + raise STFError("没有可用的在线空闲设备", "offline") + return free[0]["serial"] + + def release(self): + if not self.serial: + return + # 直连模式(remote_adb_url == serial):不 disconnect + # STF agent 可能共享同一个 adb 连接,disconnect 会导致 STF 设备离线后重新连接 + if self.remote_adb_url and self.remote_adb_url != self.serial: + adb_disconnect(self.remote_adb_url) + try: + self.stf.remote_disconnect(self.serial) + except Exception: + pass + try: + self.stf.release(self.serial) + _log.info(f"[{self.serial}] 已释放") + except Exception as e: + _log.warning(f"[{self.serial}] 释放失败: {e}") + + +# ================== 全局 worker 状态注册表(供 web_server 读取) ================== +_WORKERS_LOCK = threading.Lock() +_WORKERS = {} # serial -> status dict + + +def get_all_worker_status(): + with _WORKERS_LOCK: + return list(_WORKERS.values()) + + +def _update_status(serial, **fields): + with _WORKERS_LOCK: + s = _WORKERS.setdefault(serial, {"serial": serial}) + s.update(fields) + s["last_heartbeat"] = time.time() + + +def _remove_worker(serial): + with _WORKERS_LOCK: + _WORKERS.pop(serial, None) + + +def get_worker_heartbeat(serial): + """返回 worker 最近一次心跳时间,没有返回 0。""" + with _WORKERS_LOCK: + return _WORKERS.get(serial, {}).get("last_heartbeat", 0) + + +# ================== 心跳看门狗 ================== +class _Watchdog(threading.Thread): + """后台线程:检测 worker 心跳超时,自动标记异常并设置 stop 信号。 + + worker 卡死(如 u2 操作 hang 住)时,看门狗能发现并标记状态, + 避免设备一直被占用却不干活。 + """ + + def __init__(self): + super().__init__(daemon=True, name="worker-watchdog") + self._stop = threading.Event() + + def run(self): + while not self._stop.wait(_WATCHDOG_INTERVAL): + try: + self._check() + except Exception as e: + _log.error(f"看门狗检查异常: {e}") + + def _check(self): + now = time.time() + with _WORKERS_LOCK: + stale = [] + for serial, s in _WORKERS.items(): + # 只检查运行中的 worker + if s.get("status") not in ("running", "connecting"): + continue + hb = s.get("last_heartbeat", 0) + if hb and now - hb > _HEARTBEAT_TIMEOUT: + stale.append(serial) + for serial in stale: + _log.error(f"[{serial}] 心跳超时 {now - get_worker_heartbeat(serial):.0f}s,标记卡死") + _update_status(serial, status="error", + last_error=f"心跳超时 {_HEARTBEAT_TIMEOUT}s,worker 可能卡死") + + def stop(self): + self._stop.set() + + +_watchdog = None + + +def start_watchdog(): + """启动看门狗(web_server 启动时调一次)。""" + global _watchdog + if _watchdog is None or not _watchdog.is_alive(): + _watchdog = _Watchdog() + _watchdog.start() + _log.info("心跳看门狗已启动") + + +def stop_watchdog(): + """停止看门狗。""" + global _watchdog + if _watchdog: + _watchdog.stop() + _watchdog = None + + +# ================== 通用 Worker 基类 ================== +class BaseWorker(threading.Thread): + """通用 worker 基类。 + + 自动处理:STF 占用/释放、u2 连接、状态上报、异常捕获、stop 信号、心跳。 + 子类只需实现 run_task(d) 方法,专注业务逻辑。 + + 生命周期(基类 run() 已封装,不要重写): + 1. acquire 设备(STF 占用 + adb 连接) + 2. u2.connect 拿到 d + 3. 调用 setup(d) ← 子类可选钩子,做初始化 + 4. 调用子类 run_task(d) ← 业务逻辑 + 5. 调用 teardown(d) ← 子类可选钩子,做清理 + 6. finally: release 设备 + + 子类可选钩子: + setup(d) — run_task 前的初始化(如启动 app、检查登录)。默认空。 + teardown(d) — run_task 后的清理(无论成功失败)。默认空。 + on_error(d, err) — run_task 抛异常时回调。默认记录日志。 + + 子类可用工具方法: + self.stopped() — 判断是否被要求停止(循环里检查) + self.set_action(s) — 上报当前动作(同时刷新心跳) + self.set_progress() — 上报进度字段(同时刷新心跳) + self.heartbeat() — 手动刷新心跳(长操作中间调) + + 进度上报规范(通用,适配任意 app): + self.set_progress(done=5, total=80, unit="视频") + - done: 已完成数量(int) + - total: 总数量(int) + - unit: 计数单位(str,如 "视频"/"轮次"/"条") + - action_counts: 操作计数 dict(可选,如 {"like": 3, "comment": 1}) + - 也可传任意业务字段,前端会原样展示在"进度"列 + 示例: + self.set_progress(done=5, total=80, unit="视频", + action_counts={"like": 3, "comment": 1}) + 前端展示:进度条 + "5/80 视频" + 操作计数徽章 + + 运行时长终止(通用,适配任意 app): + self.max_duration — 最大运行时长(秒),0=不限时 + self.is_time_up() — 判断是否已达 max_duration + 子类在循环里检查 self.stopped() or self.is_time_up() + 到时自动停(自然结束,不是 error),状态=done + + 异常分类(影响上层重试策略): + DeviceOfflineError — 设备掉线/不可用,不重试(换设备也没用) + 其他异常 — 可重试 + """ + + def __init__(self, stf_client, serial, params=None, daemon=True): + super().__init__(daemon=daemon) + self.stf = stf_client + self.serial = serial + self.params = params or {} + self._stop_flag = threading.Event() + self.d = None # u2.Device,run_task 里用 + # 通用进度字段(子类通过 set_progress 上报) + self._progress = {"done": 0, "total": 0, "unit": "", + "action_counts": {}} + # 通用运行时长终止(子类设 max_duration>0 启用) + self.max_duration = 0 # 秒,0=不限时 + self._start_time = 0.0 # run_task 开始时间戳 + + def _start_timer(self): + """子类在 run_task 开头调用,启动运行时长计时。""" + self._start_time = time.time() + + def is_time_up(self): + """是否已达最大运行时长。max_duration=0 时永远返回 False。""" + if self.max_duration <= 0 or self._start_time <= 0: + return False + return (time.time() - self._start_time) >= self.max_duration + + def elapsed(self): + """已运行时长(秒)。run_task 未启动时返回 0。""" + if self._start_time <= 0: + return 0 + return int(time.time() - self._start_time) + + def stop(self): + """外部调用,请求停止(子类应在循环里检查 self.stopped())。""" + self._stop_flag.set() + + def stopped(self): + """子类在循环里调用,判断是否被要求停止。""" + return self._stop_flag.is_set() + + def set_action(self, action): + """上报当前动作(子类用)。同时刷新心跳。""" + _update_status(self.serial, current_action=action) + + def set_progress(self, **fields): + """上报进度字段(子类用)。同时刷新心跳。 + + 通用字段(前端会解析展示): + done (int) — 已完成数量 + total (int) — 总数量 + unit (str) — 计数单位("视频"/"轮次"/"条") + action_counts (dict) — 操作计数 {"like": 3, "comment": 1} + + 也可传任意业务字段,会合并到 progress 里,前端原样展示。 + + 示例: + self.set_progress(done=5, total=80, unit="视频", + action_counts={"like": 3, "comment": 1}) + """ + self._progress.update(fields) + # 同步到全局状态字典,前端通过 get_status 读取 + _update_status(self.serial, progress=dict(self._progress)) + + def heartbeat(self): + """手动刷新心跳。长操作中间调,避免看门狗误判卡死。""" + _update_status(self.serial, last_heartbeat=time.time()) + + # ================== 子类实现/可选钩子 ================== + def run_task(self, d): + """子类实现:拿到 u2.Device d,做具体业务。 + + 不需要管 STF 占用/释放、异常、状态上报,基类已处理。 + 循环里记得检查 self.stopped() 以便响应停止请求。 + """ + raise NotImplementedError + + def setup(self, d): + """run_task 前的初始化钩子。默认空,子类按需重写。 + + 典型用途:启动 app、检查登录状态、进入主页。 + 抛异常会中断任务(走 on_error)。 + """ + + def teardown(self, d): + """run_task 后的清理钩子(无论成功失败都调)。默认空,子类按需重写。 + + 典型用途:退出 app、清理临时文件。 + 抛异常只记日志,不影响主流程。 + """ + + def on_error(self, d, error): + """run_task 抛异常时的回调。默认空,子类按需重写。 + + 典型用途:截图保存现场、上报错误详情。 + """ + + # ================== 基类主循环(不要重写) ================== + def run(self): + """基类主循环:不要重写。子类实现 run_task + 可选钩子。""" + device = STFDevice(self.stf, serial=self.serial) + _update_status(self.serial, status="connecting", stf_occupied=False, + last_error="", remote_adb_url="", model="", + current_action="") + try: + device.acquire() + remote = device.remote_adb_url + # u2.connect 可能 hang 住(atx-agent 无响应),用线程池+超时保护 + self.d = self._u2_connect_with_timeout(remote) + if self.d is None: + raise STFError(f"u2.connect 超时 {_U2_CONNECT_TIMEOUT}s,atx-agent 无响应", "offline") + _log.info(f"[{self.serial}] u2 连接成功") + model = "" + try: + info = self._u2_info_with_timeout() + if info: + model = info.get("productName") or "" + except Exception: + pass + _update_status(self.serial, status="running", stf_occupied=True, + remote_adb_url=remote, model=model) + + # setup 钩子 + try: + self.setup(self.d) + except Exception as e: + _log.error(f"[{self.serial}] setup 异常: {e}") + raise + + # 业务逻辑 + _log.info(f"[{self.serial}] 进入 run_task,开始执行业务逻辑") + self.run_task(self.d) + _log.info(f"[{self.serial}] run_task 执行完毕") + + # teardown 钩子(成功路径) + try: + self.teardown(self.d) + except Exception as e: + _log.warning(f"[{self.serial}] teardown 异常: {e}") + + if not self.stopped(): + _update_status(self.serial, status="done") + + except DeviceOfflineError as e: + # 设备掉线,不重试 + _log.error(f"[{self.serial}] 设备离线: {e}") + _update_status(self.serial, status="error", last_error=str(e)) + self._safe_on_error(e) + except Exception as e: + err_str = str(e) + # 识别 Windows TCP 端口耗尽(WinError 10048)或 adb server 连接失败 + # 这类错误是临时性的,重试时需要更长退避等端口释放 + is_transient = ("10048" in err_str + or "connect to adb server failed" in err_str + or "Unable to connect to uiautomator2 server" in err_str) + if is_transient: + _log.error(f"[{self.serial}] ADB/u2 连接错误(可能端口耗尽): {e}") + _update_status(self.serial, status="error", + last_error=f"[transient] {e}") + else: + _log.error(f"[{self.serial}] 异常: {e}", exc_info=True) + _update_status(self.serial, status="error", last_error=str(e)) + self._safe_on_error(e) + finally: + try: + device.release() + except Exception as e: + _log.warning(f"[{self.serial}] release 异常: {e}") + # 只在仍在运行/连接中时才标记 released; + # done/error/failed 等终态保持不变,避免覆盖业务结果 + with _WORKERS_LOCK: + cur = _WORKERS.get(self.serial, {}).get("status") + if cur in ("running", "connecting"): + _update_status(self.serial, stf_occupied=False, status="released") + else: + _update_status(self.serial, stf_occupied=False) + + def _u2_connect_with_timeout(self, remote): + """u2.connect 带超时保护,避免 atx-agent 无响应时永久 hang。""" + with ThreadPoolExecutor(max_workers=1) as pool: + fut = pool.submit(u2.connect, remote) + try: + return fut.result(timeout=_U2_CONNECT_TIMEOUT) + except FuturesTimeout: + _log.error(f"[{self.serial}] u2.connect 超时 {_U2_CONNECT_TIMEOUT}s") + return None + except Exception as e: + _log.error(f"[{self.serial}] u2.connect 异常: {e}") + return None + + def _u2_info_with_timeout(self): + """self.d.info 带超时保护,避免 atx-agent 无响应时永久 hang。""" + with ThreadPoolExecutor(max_workers=1) as pool: + fut = pool.submit(lambda: self.d.info) + try: + return fut.result(timeout=_U2_INFO_TIMEOUT) + except FuturesTimeout: + _log.warning(f"[{self.serial}] d.info 超时 {_U2_INFO_TIMEOUT}s,跳过型号获取") + return None + except Exception as e: + _log.warning(f"[{self.serial}] d.info 异常: {e}") + return None + + def _safe_on_error(self, error): + """安全调用 on_error 钩子,不让钩子异常影响主流程。""" + try: + self.on_error(self.d, error) + except Exception as e: + _log.warning(f"[{self.serial}] on_error 钩子异常: {e}") diff --git a/core/logger.py b/core/logger.py new file mode 100644 index 0000000..ab2b440 --- /dev/null +++ b/core/logger.py @@ -0,0 +1,84 @@ +"""统一日志系统:按模块分文件,带时间戳和级别,自动滚动。 + +用法: + from core.logger import get_logger + log = get_logger("task") # 写 logs/task.log + 控制台 + log.info("开始任务") + log.error("失败", exc_info=True) + +模块划分: + core — 核心程序(STF/adb/worker/task_manager) + task — 任务执行(worker 业务逻辑) + web — web_server 请求/管理 + action — 操作执行(点赞/评论等) + +所有日志同时输出到控制台和对应文件,logs/ 目录自动创建。 +文件按 10MB 滚动,保留 5 个历史文件。 +""" +import os +import logging +from logging.handlers import RotatingFileHandler + +_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs") +os.makedirs(_LOG_DIR, exist_ok=True) + +# 已创建的 logger,避免重复添加 handler +_LOGGERS = {} + +# 模块 → 文件名映射 +_MODULE_FILES = { + "core": "core.log", + "task": "task.log", + "web": "web.log", + "action": "action.log", +} + +_FORMAT = "%(asctime)s [%(levelname)s] [%(name)s] %(message)s" +_DATE_FMT = "%Y-%m-%d %H:%M:%S" + + +def get_logger(name="core"): + """获取指定模块的 logger。 + + name: 模块名(core/task/web/action),决定写哪个文件。 + 也可传子模块名如 "task.douyin",会归到 task.log。 + 返回配置好的 logging.Logger。 + """ + if name in _LOGGERS: + return _LOGGERS[name] + + # 归类到对应文件:取顶层模块名 + top = name.split(".")[0] + filename = _MODULE_FILES.get(top, "core.log") + + log = logging.getLogger(name) + log.setLevel(logging.DEBUG) + # 避免向 root logger 传播导致重复输出 + log.propagate = False + + fmt = logging.Formatter(_FORMAT, _DATE_FMT) + + # 文件 handler:10MB 滚动,保留 5 个 + file_path = os.path.join(_LOG_DIR, filename) + fh = RotatingFileHandler(file_path, maxBytes=10 * 1024 * 1024, + backupCount=5, encoding="utf-8") + fh.setLevel(logging.DEBUG) + fh.setFormatter(fmt) + + # 控制台 handler + ch = logging.StreamHandler() + ch.setLevel(logging.INFO) + ch.setFormatter(fmt) + + log.addHandler(fh) + log.addHandler(ch) + + _LOGGERS[name] = log + return log + + +# 兼容旧代码的 print 风格:紧急排查时可临时用 +def log_print(msg, level="info", module="core"): + """print 的替代品,转发到 logging。""" + log = get_logger(module) + getattr(log, level, log.info)(msg) diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000..a6e6842 --- /dev/null +++ b/core/models.py @@ -0,0 +1,242 @@ +"""SQLAlchemy 数据模型 + db 初始化(用户/设备分组/任务计划)。 + +为什么放这里: + web_server 和 task_manager 都要访问这些模型,放 core/ 避免循环依赖。 + web_server 负责初始化 db(app context),task_manager 只读写数据。 + +模型说明: + User — 后台用户(Flask-Login 认证) + DeviceGroup — 设备分组(持久化,替代旧 groups.json) + TaskJob — 任务计划(持久化,替代旧 jobs.json) + +字段设计: + DeviceGroup.serials 用 JSON 存列表(SQLite 无数组类型) + TaskJob.target/params/schedule/retry 用 JSON 存嵌套结构 + Flask-Admin 默认用 TextArea 编辑 JSON,够用且通用 +""" +import os +import json +import hashlib + +from flask_sqlalchemy import SQLAlchemy +from flask_login import UserMixin + +from core.logger import get_logger + +_log = get_logger("core.models") + +db = SQLAlchemy() + + +class User(UserMixin, db.Model): + """后台用户。""" + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password_hash = db.Column(db.String(120), nullable=False) + is_admin = db.Column(db.Boolean, default=True) + + def set_password(self, password): + self.password_hash = hashlib.sha256(password.encode()).hexdigest() + + def check_password(self, password): + return self.password_hash == hashlib.sha256(password.encode()).hexdigest() + + def __repr__(self): + return f"" + + +class DeviceGroup(db.Model): + """设备分组(替代旧 groups.json 的 DeviceGroup 类)。""" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + serials = db.Column(db.Text, default="[]") # JSON 列表 + description = db.Column(db.Text, default="") + + def get_serials(self): + try: + return json.loads(self.serials or "[]") + except Exception: + return [] + + def set_serials(self, lst): + self.serials = json.dumps(lst or [], ensure_ascii=False) + + def to_dict(self): + return {"name": self.name, "serials": self.get_serials(), + "description": self.description or ""} + + def __repr__(self): + return f"" + + +class TaskJob(db.Model): + """任务计划(替代旧 jobs.json 的 TaskJob 类)。 + + 字段含义和旧 TaskJob 一致,只是持久化方式从 JSON 文件改到 SQLite。 + """ + id = db.Column(db.String(32), primary_key=True) # uuid 前 8 位 + name = db.Column(db.String(120), nullable=False) + task_type = db.Column(db.String(60), default="douyin_nurture") + target = db.Column(db.Text, default='{"mode":"all"}') # JSON + params = db.Column(db.Text, default="{}") # JSON + schedule = db.Column(db.Text, default='{"mode":"once"}') # JSON + retry = db.Column(db.Text, default='{"max_attempts":1,"delay":60}') # JSON + enabled = db.Column(db.Boolean, default=True) + + def _load_json(self, field, default): + try: + return json.loads(getattr(self, field) or default) + except Exception: + return json.loads(default) + + def _dump_json(self, field, value): + setattr(self, field, json.dumps(value or {}, ensure_ascii=False)) + + def get_target(self): return self._load_json("target", '{"mode":"all"}') + def set_target(self, v): self._dump_json("target", v) + def get_params(self): return self._load_json("params", "{}") + def set_params(self, v): self._dump_json("params", v) + def get_schedule(self): return self._load_json("schedule", '{"mode":"once"}') + def set_schedule(self, v): self._dump_json("schedule", v) + def get_retry(self): return self._load_json("retry", '{"max_attempts":1,"delay":60}') + def set_retry(self, v): self._dump_json("retry", v) + + def to_dict(self): + return {"id": self.id, "name": self.name, "task_type": self.task_type, + "target": self.get_target(), "params": self.get_params(), + "schedule": self.get_schedule(), "retry": self.get_retry(), + "enabled": self.enabled} + + def __repr__(self): + return f"" + + +class ApkFile(db.Model): + """上传的 APK 文件元信息(应用管理功能)。""" + id = db.Column(db.String(32), primary_key=True) # uuid 前 8 位 + filename = db.Column(db.String(255), nullable=False) # 磁盘文件名 (id.apk) + display_name = db.Column(db.String(120), default="") # 应用名 + package_name = db.Column(db.String(200), default="") # 包名 + version_name = db.Column(db.String(50), default="") # 版本号 + version_code = db.Column(db.Integer, default=0) # 版本码 + size = db.Column(db.Integer, default=0) # 文件大小(字节) + upload_time = db.Column(db.String(20), default="") # 上传时间 + + def to_dict(self): + return {"id": self.id, "filename": self.filename, + "display_name": self.display_name or "", + "package_name": self.package_name or "", + "version_name": self.version_name or "", + "version_code": self.version_code or 0, + "size": self.size or 0, + "upload_time": self.upload_time or ""} + + def __repr__(self): + return f"" + + +def init_db(app): + """在 Flask app context 里初始化数据库 + 创建默认管理员。 + + web_server 启动时调用。自动迁移旧 groups.json/jobs.json 到 SQLite。 + """ + db.init_app(app) + with app.app_context(): + db.create_all() + _ensure_default_admin() + _migrate_old_json() + + +def _ensure_default_admin(): + """首次启动创建默认管理员 admin/admin123。""" + if not User.query.filter_by(username="admin").first(): + u = User(username="admin", is_admin=True) + u.set_password("admin123") + db.session.add(u) + db.session.commit() + _log.info("已创建默认管理员 admin/admin123,请及时改密码") + + +def _migrate_old_json(): + """把旧 groups.json / jobs.json 迁移到 SQLite(仅首次)。 + + 迁移策略: + 1. 仅当数据库对应表为空时才迁移(首次启动场景) + 2. 迁移成功后立即把 JSON 文件重命名为 .json.migrated, + 保留备份但永不再迁移——避免"用户删完全部任务后重启又从旧文件复原" + 3. 若数据库已有数据但 JSON 文件仍在(历史残留),直接归档, + 避免将来数据库被清空后又触发迁移导致已删任务复原 + 4. 迁移任务时顺手剔除已废弃的 comment action 配置, + 避免 _load 阶段还要再写回一次 + """ + from config import DATA_DIR + + # 迁移分组 + groups_file = os.path.join(DATA_DIR, "groups.json") + if os.path.exists(groups_file) and DeviceGroup.query.count() == 0: + try: + with open(groups_file, encoding="utf-8") as f: + groups = json.load(f) + for g in groups: + if not DeviceGroup.query.filter_by(name=g["name"]).first(): + row = DeviceGroup(name=g["name"], + description=g.get("description", "")) + row.set_serials(g.get("serials", [])) + db.session.add(row) + db.session.commit() + _log.info(f"已迁移 {len(groups)} 个分组到数据库") + _archive_migrated(groups_file) + except Exception as e: + _log.error(f"迁移 groups.json 失败: {e}") + + # 迁移任务 + jobs_file = os.path.join(DATA_DIR, "jobs.json") + if os.path.exists(jobs_file) and TaskJob.query.count() == 0: + try: + with open(jobs_file, encoding="utf-8") as f: + jobs = json.load(f) + for j in jobs: + if TaskJob.query.get(j["id"]): + continue + params = j.get("params", {}) + # 剔除已废弃的 comment action,避免带入数据库 + actions = params.get("actions", {}) + if "comment" in actions: + del actions["comment"] + _log.info(f"迁移任务 {j.get('name')}: 已剔除废弃的 comment 配置") + row = TaskJob(id=j["id"], name=j["name"], + task_type=j.get("task_type", "douyin_nurture"), + enabled=j.get("enabled", True)) + row.set_target(j.get("target", {"mode": "all"})) + row.set_params(params) + row.set_schedule(j.get("schedule", {"mode": "once"})) + row.set_retry(j.get("retry", {"max_attempts": 1, "delay": 60})) + db.session.add(row) + db.session.commit() + _log.info(f"已迁移 {len(jobs)} 个任务到数据库") + _archive_migrated(jobs_file) + except Exception as e: + _log.error(f"迁移 jobs.json 失败: {e}") + + # 清理历史残留:数据库已有数据但 JSON 文件仍在(修复前遗留下来的文件)。 + # 不归档的话,用户哪天删光所有任务/分组,count==0 又会触发迁移导致已删数据复原。 + if DeviceGroup.query.count() > 0 and os.path.exists(groups_file): + _archive_migrated(groups_file) + if TaskJob.query.count() > 0 and os.path.exists(jobs_file): + _archive_migrated(jobs_file) + + +def _archive_migrated(file_path): + """把已迁移的 JSON 文件重命名为 .migrated,避免下次重启再迁移。 + + 保留备份以便排查,但 _migrate_old_json 的 exists 判断会跳过它。 + 重命名失败只警告不抛出,不影响启动。 + """ + archived = file_path + ".migrated" + try: + if os.path.exists(archived): + os.remove(archived) + os.rename(file_path, archived) + _log.info(f"已归档迁移文件: {os.path.basename(file_path)} -> {os.path.basename(archived)}") + except Exception as e: + _log.warning(f"归档 {file_path} 失败(不影响运行): {e}") diff --git a/core/stf_client.py b/core/stf_client.py new file mode 100644 index 0000000..5ca2b9d --- /dev/null +++ b/core/stf_client.py @@ -0,0 +1,210 @@ +"""OpenSTF REST API 客户端封装。 + +负责:设备列表查询、占用、远程 ADB 隧道建立、释放。 +不负责实际 UI 操作(那是 DeviceWorker 的事)。 + +健壮性设计: + - 所有请求带 timeout,避免 STF 网关 504 时长时间卡死 + - remoteConnect 带重试,STF provider 偶尔慢启动 + - 友好错误分类:设备离线 / 网络不通 / STF 内部错误,便于上层决策 + - 占用冲突自动重试(STF 偶发 400 "device already in use") +""" +import time + +import requests + +from config import STF_URL, STF_TOKEN +from core.logger import get_logger + +_log = get_logger("core.stf") + +# 请求超时(秒)。connect 超时 + read 超时 +_TIMEOUT = (5, 15) +# remoteConnect 重试次数(STF provider 慢启动时需要重试) +_RC_RETRIES = 3 +# remoteConnect 重试间隔 +_RC_DELAY = 2 + + +class STFError(Exception): + """STF 相关错误基类。""" + + def __init__(self, message, code=""): + super().__init__(message) + self.code = code # 错误码:offline / network / conflict / server / unknown + + +class DeviceOfflineError(STFError): + """设备离线/不可用。""" + + +class STFNetworkError(STFError): + """STF 服务不可达。""" + + +class DeviceConflictError(STFError): + """设备已被占用。""" + + +def _headers(): + return {"Authorization": f"Bearer {STF_TOKEN}"} + + +class STFClient: + """OpenSTF REST API 封装。""" + + def __init__(self, base_url=STF_URL, token=STF_TOKEN): + self.base_url = base_url + self.headers = _headers() + + # ================== 设备查询 ================== + def list_all_devices(self): + """返回 STF 上所有设备(含状态)。""" + try: + resp = requests.get(f"{self.base_url}/api/v1/devices", + headers=self.headers, timeout=_TIMEOUT) + resp.raise_for_status() + return resp.json().get("devices", []) + except requests.exceptions.ConnectionError as e: + raise STFNetworkError(f"STF 服务不可达: {e}", "network") from e + except requests.exceptions.Timeout as e: + raise STFNetworkError(f"STF 请求超时: {e}", "network") from e + except requests.exceptions.RequestException as e: + _log.error(f"获取设备列表失败: {e}") + raise STFError(f"获取设备列表失败: {e}", "server") from e + + def list_free_devices(self): + """返回可占用的空闲设备。""" + return [d for d in self.list_all_devices() + if d.get("present") and d.get("ready") + and not d.get("using") and d.get("owner") is None] + + def list_my_devices(self): + """返回当前账户已占用的设备。""" + try: + resp = requests.get(f"{self.base_url}/api/v1/user/devices", + headers=self.headers, timeout=_TIMEOUT) + resp.raise_for_status() + return resp.json().get("devices", []) + except requests.exceptions.RequestException as e: + _log.error(f"获取已占用设备失败: {e}") + return [] + + # ================== 占用/释放 ================== + def occupy(self, serial, retries=2): + """占用设备。返回 (ok, msg)。 + + 冲突(已被占用)时自动重试,偶发 400 "already in use" 可能是脏状态。 + """ + for attempt in range(1, retries + 1): + try: + r = requests.post(f"{self.base_url}/api/v1/user/devices/{serial}", + headers=self.headers, timeout=_TIMEOUT) + if r.status_code in (200, 201): + return True, r.text[:200] + if r.status_code == 400 and "already" in r.text.lower(): + # 已被占用(可能是自己之前占用没释放干净),尝试先释放再占 + _log.warning(f"{serial} 占用冲突,尝试清理后重试 ({attempt}/{retries})") + try: + requests.delete(f"{self.base_url}/api/v1/user/devices/{serial}", + headers=self.headers, timeout=_TIMEOUT) + except Exception: + pass + time.sleep(1) + continue + return False, f"HTTP {r.status_code}: {r.text[:200]}" + except requests.exceptions.RequestException as e: + _log.warning(f"{serial} 占用请求异常 ({attempt}/{retries}): {e}") + if attempt < retries: + time.sleep(1) + return False, str(e) + return False, "占用冲突重试耗尽" + + def release(self, serial): + """释放设备占用(含远程断开)。""" + self.remote_disconnect(serial) + try: + requests.delete(f"{self.base_url}/api/v1/user/devices/{serial}", + headers=self.headers, timeout=_TIMEOUT) + except requests.exceptions.RequestException as e: + _log.warning(f"释放 {serial} 请求失败: {e}") + + def release_all_mine(self): + """释放当前账户占用的所有设备(清理用)。""" + released = [] + for d in self.list_my_devices(): + serial = d["serial"] + try: + self.release(serial) + released.append(serial) + _log.info(f"已释放 {serial}") + except Exception as e: + _log.error(f"释放 {serial} 失败: {e}") + return released + + # ================== 远程 ADB 隧道 ================== + def remote_connect(self, serial): + """建立远程 ADB 隧道,返回 remoteConnectUrl。 + + STF provider 慢启动或设备掉线时会 504,这里带重试。 + 设备真离线时快速失败,不长时间卡住 worker。 + """ + last_err = None + for attempt in range(1, _RC_RETRIES + 1): + try: + conn = requests.post( + f"{self.base_url}/api/v1/user/devices/{serial}/remoteConnect", + headers=self.headers, timeout=_TIMEOUT, + ) + # 504 = STF 网关等 provider 响应超时,通常是设备掉线或 provider 卡死 + if conn.status_code == 504: + last_err = f"STF 网关超时(504),设备可能掉线" + _log.warning(f"{serial} remoteConnect 504 ({attempt}/{_RC_RETRIES})") + if attempt < _RC_RETRIES: + time.sleep(_RC_DELAY) + continue + raise DeviceOfflineError( + f"{serial} remoteConnect 超时,设备可能掉线或 provider 卡死", + "offline", + ) + conn.raise_for_status() + data = conn.json() + if not data.get("success"): + raise STFError(f"远程连接失败: {data.get('description')}", "server") + url = (data.get("remoteConnectUrl") or data.get("remoteAdbUrl") + or data.get("remote_adb_url") or data.get("adbUrl") + or data.get("url")) + if not url: + raise STFError("STF 返回成功但无 remoteConnectUrl", "server") + return url + except DeviceOfflineError: + raise + except requests.exceptions.ConnectionError as e: + last_err = str(e) + _log.warning(f"{serial} remoteConnect 网络异常 ({attempt}/{_RC_RETRIES}): {e}") + if attempt < _RC_RETRIES: + time.sleep(_RC_DELAY) + except requests.exceptions.Timeout as e: + last_err = str(e) + _log.warning(f"{serial} remoteConnect 超时 ({attempt}/{_RC_RETRIES}): {e}") + if attempt < _RC_RETRIES: + time.sleep(_RC_DELAY) + except requests.exceptions.RequestException as e: + last_err = str(e) + _log.error(f"{serial} remoteConnect 请求异常: {e}") + raise STFError(f"远程连接请求失败: {e}", "server") from e + # 重试耗尽 + raise DeviceOfflineError( + f"{serial} remoteConnect 重试 {_RC_RETRIES} 次失败: {last_err}", + "offline", + ) + + def remote_disconnect(self, serial): + """断开远程 ADB 隧道。""" + try: + requests.post( + f"{self.base_url}/api/v1/user/devices/{serial}/remoteDisconnect", + headers=self.headers, timeout=_TIMEOUT, + ) + except requests.exceptions.RequestException: + pass # 断开失败不影响主流程 diff --git a/core/task_manager.py b/core/task_manager.py new file mode 100644 index 0000000..efa1677 --- /dev/null +++ b/core/task_manager.py @@ -0,0 +1,734 @@ +"""通用任务管理框架:任务类型注册 + 设备分组 + 任务计划 + 定时调度 + 重试 + 持久化。 + +设计目标:可扩展,未来加非抖音任务只需注册新的 Task 类。 + +核心概念: + TaskType — 任务类型(如"抖音养号"),可注册,含默认参数和 worker 工厂 + DeviceGroup — 设备分组,持久化到 SQLite(core.models.DeviceGroup) + TaskJob — 任务计划(类型+目标+参数+调度+重试),持久化到 SQLite(core.models.TaskJob) + TaskManager — 统管调度器、分组、任务、运行实例、状态 + +定时:APScheduler BackgroundScheduler,cron 触发后为每个目标设备起 worker。 + 支持三种调度模式: + once — 手动/立即执行,不注册 cron + cron — 仅注册启动 cron(到点启动 worker) + cron_stop — 注册启动 cron + 停止 cron(到点启动,到点停止) +重试:单设备 worker 失败后按 retry.max_attempts 重试,间隔 retry.delay。 +并发:同一 serial 同时只允许一个 worker,避免冲突。 +""" +import os +import json +import time +import uuid +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed + +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from config import DATA_DIR +from core.logger import get_logger +from core.models import db, DeviceGroup as GroupRow, TaskJob as JobRow +from .stf_client import STFClient, DeviceOfflineError +from .adb_helper import get_foreground_app, adb_connect_light, adb_disconnect +from .device_worker import ( + get_all_worker_status, _update_status, _remove_worker, + _WORKERS, _WORKERS_LOCK, + start_watchdog, stop_watchdog, +) +from tasks import list_task_types, get_task_class + +_log = get_logger("core.tm") + + +# ================== 设备分组 ================== +class DeviceGroup: + def __init__(self, name, serials=None, description=""): + self.name = name + self.serials = serials or [] + self.description = description + + def to_dict(self): + return {"name": self.name, "serials": self.serials, "description": self.description} + + @classmethod + def from_dict(cls, d): + return cls(d["name"], d.get("serials", []), d.get("description", "")) + + +# ================== 任务计划 ================== +class TaskJob: + """一个任务计划:什么任务、跑哪些设备、什么参数、何时跑、失败重试策略。""" + + def __init__(self, job_id=None, name="", task_type="douyin_nurture", + target=None, params=None, schedule=None, retry=None, enabled=True): + self.id = job_id or uuid.uuid4().hex[:8] + self.name = name + self.task_type = task_type + # target: {"mode": "all"|"group"|"serial", "group_name": "", "serial": ""} + self.target = target or {"mode": "all"} + self.params = params or {} + self.schedule = schedule or {"mode": "once"} # {"mode":"once"|"cron", "cron":"0 9 * * *"} + self.retry = retry or {"max_attempts": 1, "delay": 60} + self.enabled = enabled + + def to_dict(self): + return {"id": self.id, "name": self.name, "task_type": self.task_type, + "target": self.target, "params": self.params, + "schedule": self.schedule, "retry": self.retry, "enabled": self.enabled} + + @classmethod + def from_dict(cls, d): + return cls(d["id"], d["name"], d["task_type"], d.get("target"), + d.get("params"), d.get("schedule"), d.get("retry"), d.get("enabled", True)) + + def resolve_serials(self, manager): + """根据 target 解析出实际要跑的 serial 列表。""" + mode = self.target.get("mode", "all") + if mode == "serial": + return [self.target["serial"]] + if mode == "group": + g = manager.groups.get(self.target.get("group_name")) + return list(g.serials) if g else [] + # all:返回所有空闲设备 + try: + return [d["serial"] for d in manager.stf.list_free_devices()] + except Exception: + return [] + + +# ================== 前台 App 扫描器 ================== +class _ForegroundScanner: + """前台 App 扫描器:手动触发,获取所有在线设备的前台 App。 + + 设计原则:**不打扰设备**,扫描过程不会让设备退出当前 App。 + + 策略(按设备状态区分获取逻辑): + - worker 运行中设备:用已有 remote_adb_url 直接查询(已有 adb 连接,无额外开销) + - 空闲设备:直接 adb connect → 查询 → adb disconnect(绕过 STF,不打扰设备) + - 被别人占用的设备:标记 "他人占用" + - STF occupy/release 会打扰设备(可能退回桌面),绝不使用 + + serial 格式为 IP:5555(设备本身的 adb 网络地址),可直接 adb connect。 + adb connect/disconnect 只建立/断开调试连接,不影响设备 UI。 + dumpsys window 只读取窗口状态,不执行任何操作。 + """ + + def __init__(self, stf): + self.stf = stf + self._cache = {} # serial -> app_name + self._cache_lock = threading.Lock() + self._scanning = threading.Event() # 标记是否正在扫描 + self._last_scan_time = 0 # 上次扫描完成时间戳 + self._last_scan_count = 0 # 上次扫描设备数 + + def stop(self): + """兼容 TaskManager.shutdown() 调用。""" + pass + + def get(self, serial): + """读取缓存的前台 App。""" + with self._cache_lock: + return self._cache.get(serial) + + @property + def is_scanning(self): + return self._scanning.is_set() + + @property + def last_scan_time(self): + return self._last_scan_time + + def scan_once(self): + """手动触发一次扫描(后台线程执行,不阻塞调用者)。 + + 如果已有扫描在进行中,直接返回 False。 + """ + if self._scanning.is_set(): + return False + t = threading.Thread(target=self._scan_all, name="fg-scan-once", daemon=True) + t.start() + return True + + def _scan_all(self): + """扫描所有在线设备(后台线程执行)。 + + 按设备归属分四类处理: + 1. worker 运行中:用已有 remote_adb_url 直接查询(无额外开销) + 2. 自己账户占用但无 worker:调用 STF remoteConnect 获取隧道查询 + (不 occupy/release,不打扰设备 UI) + 3. 完全空闲设备(using=False):尝试轻量 adb connect serial + (单次尝试,不 kill-server,不影响其他 worker) + 4. 被他人占用:标记 "(他人占用)" + """ + self._scanning.set() + _log.info("前台 App 扫描已启动") + try: + all_devices = self.stf.list_all_devices() + except Exception as e: + _log.error("前台 App 扫描: 获取设备列表失败: %s", e) + self._scanning.clear() + return + + # 自己账户已占用的设备列表(用于区分"自己占用"vs"他人占用") + try: + my_serials = {d["serial"] for d in self.stf.list_my_devices()} + except Exception: + my_serials = set() + + worker_status = {w["serial"]: w for w in get_all_worker_status()} + + # 分类设备 + have_conn = {} # serial -> remote_adb_url(worker 运行中,已有 adb 连接) + my_owned = [] # 自己账户占用但无 running worker,需 remoteConnect 获取隧道 + free_serials = [] # 完全空闲设备,尝试轻量 adb connect + skip_serials = [] # 被他人占用 + + for dev in all_devices: + if not dev.get("present"): + continue + serial = dev.get("serial", "") + if not serial: + continue + w = worker_status.get(serial, {}) + url = w.get("remote_adb_url") + if url and w.get("status") in ("running", "connecting"): + # 1. 设备正在执行任务,已有 adb 连接,直接查询 + have_conn[serial] = url + elif serial in my_serials: + # 2. 自己账户占用但无 worker,可安全 remoteConnect(不打扰设备) + my_owned.append(serial) + elif dev.get("using"): + # 4. 被他人占用 + skip_serials.append(serial) + else: + # 3. 完全空闲,尝试轻量 adb connect(不 kill-server) + free_serials.append(serial) + + results = {} + _log.info("前台 App 扫描分类: 运行中=%d, 自己占用=%d, 空闲=%d, 他人占用=%d", + len(have_conn), len(my_owned), len(free_serials), len(skip_serials)) + + # 1. worker 运行中设备:用已有 remote_adb_url 查询(并发 10) + if have_conn: + with ThreadPoolExecutor(max_workers=min(10, len(have_conn))) as pool: + futures = {pool.submit(get_foreground_app, url): s + for s, url in have_conn.items()} + for fut in as_completed(futures, timeout=15): + s = futures[fut] + try: + results[s] = fut.result() + except Exception: + results[s] = None + + # 2. 自己占用设备:remoteConnect 获取隧道 → 查询 → 断开(并发 5,不打扰设备) + if my_owned: + with ThreadPoolExecutor(max_workers=min(5, len(my_owned))) as pool: + futures = {pool.submit(self._scan_my_owned, s): s + for s in my_owned} + for fut in as_completed(futures, timeout=30): + s = futures[fut] + try: + results[s] = fut.result() + except Exception: + results[s] = None + + # 3. 空闲设备:轻量 adb connect serial → 查询 → disconnect(并发 10) + if free_serials: + with ThreadPoolExecutor(max_workers=min(10, len(free_serials))) as pool: + futures = {pool.submit(self._scan_free, s): s + for s in free_serials} + for fut in as_completed(futures, timeout=20): + s = futures[fut] + try: + results[s] = fut.result() + except Exception: + results[s] = None + + # 4. 被别人占用的设备 + for s in skip_serials: + results[s] = "(他人占用)" + + # 更新缓存 + with self._cache_lock: + for s, app in results.items(): + self._cache[s] = app + + self._last_scan_time = time.time() + self._last_scan_count = len(results) + self._scanning.clear() + _log.info("前台 App 扫描完成: %d 台设备", len(results)) + + def _scan_my_owned(self, serial): + """自己账户占用的设备:通过 STF remoteConnect 获取隧道查询。 + + 不调用 occupy/release,只建立/断开 ADB 隧道,不打扰设备 UI。 + """ + try: + url = self.stf.remote_connect(serial) + if not url: + return None + try: + if not adb_connect_light(url): + return None + return get_foreground_app(url) + finally: + adb_disconnect(url) + self.stf.remote_disconnect(serial) + except Exception: + return None + + def _scan_free(self, serial): + """空闲设备:轻量 adb connect serial 查询(单次尝试,不 kill-server)。 + + serial 通常为 IP:5555,若设备 5555 端口未开放则快速失败。 + 绝不重试、绝不 kill-server,避免影响正在运行的 worker。 + """ + try: + if not adb_connect_light(serial): + return "(不可达)" + try: + return get_foreground_app(serial) + finally: + adb_disconnect(serial) + except Exception: + return None + + +# ================== 任务管理器 ================== +class TaskManager: + def __init__(self, stf_client=None, app=None): + self.stf = stf_client or STFClient() + self.app = app # Flask app,用于 db context + self.scheduler = BackgroundScheduler(timezone="Asia/Shanghai") + self.scheduler.start() + start_watchdog() # 启动心跳看门狗 + self.groups = {} # name -> DeviceGroup(内存业务对象) + self.jobs = {} # id -> TaskJob(内存业务对象) + self._running = {} # serial -> {"worker", "job_id", "started_at", "attempt"} + self._lock = threading.Lock() + self._fg_scanner = _ForegroundScanner(self.stf) + self._load() + + def _db(self): + """获取 db context。task_manager 在后台线程跑,需自己推 app context。""" + if self.app is None: + raise RuntimeError("TaskManager 未关联 Flask app,无法访问数据库") + return self.app.app_context() + + # ---- 持久化(SQLite,替代旧 JSON 文件)---- + def _load(self): + """从数据库加载分组和任务到内存。""" + try: + with self._db(): + for row in GroupRow.query.all(): + g = DeviceGroup(row.name, row.get_serials(), row.description or "") + self.groups[g.name] = g + dirty = False # 标记是否有任务参数需要写回 + for row in JobRow.query.all(): + j = TaskJob(job_id=row.id, name=row.name, task_type=row.task_type, + target=row.get_target(), params=row.get_params(), + schedule=row.get_schedule(), retry=row.get_retry(), + enabled=row.enabled) + # 数据规整:移除已废弃的 action 配置(如抖音的 comment 已删除) + if j.task_type == "douyin_nurture": + actions = j.params.get("actions", {}) + if "comment" in actions: + del actions["comment"] + row.set_params(j.params) + dirty = True + _log.info(f"任务 {j.name}({j.id}): 已移除废弃的 comment 配置") + self.jobs[j.id] = j + if dirty: + db.session.commit() + _log.info("已将规整后的任务参数写回数据库") + _log.info(f"从数据库加载 {len(self.groups)} 个分组, {len(self.jobs)} 个任务") + except Exception as e: + _log.error(f"从数据库加载失败: {e}") + # 恢复启用的 cron 任务 + for job in self.jobs.values(): + if job.enabled and job.schedule.get("mode") in ("cron", "cron_stop"): + self._add_cron(job) + + def _save_groups(self): + """保存所有分组到数据库(upsert)。""" + with self._db(): + for g in self.groups.values(): + row = GroupRow.query.filter_by(name=g.name).first() + if not row: + row = GroupRow(name=g.name) + db.session.add(row) + row.set_serials(g.serials) + row.description = g.description + db.session.commit() + + def _save_jobs(self): + """保存所有任务到数据库(upsert)。""" + with self._db(): + for j in self.jobs.values(): + row = JobRow.query.get(j.id) + if not row: + row = JobRow(id=j.id) + db.session.add(row) + row.name = j.name + row.task_type = j.task_type + row.set_target(j.target) + row.set_params(j.params) + row.set_schedule(j.schedule) + row.set_retry(j.retry) + row.enabled = j.enabled + db.session.commit() + + # ---- 分组管理 ---- + def add_group(self, name, serials, description=""): + g = DeviceGroup(name, serials, description) + self.groups[name] = g + self._save_groups() + return g + + def update_group(self, name, serials=None, description=None): + g = self.groups.get(name) + if not g: + return None + if serials is not None: + g.serials = serials + if description is not None: + g.description = description + self._save_groups() + return g + + def delete_group(self, name): + if name in self.groups: + del self.groups[name] + self._save_groups() + return True + return False + + # ---- 任务计划管理 ---- + def add_job(self, name, task_type, target, params, schedule, retry, enabled=True): + job = TaskJob(name=name, task_type=task_type, target=target, params=params, + schedule=schedule, retry=retry, enabled=enabled) + self.jobs[job.id] = job + if enabled and schedule.get("mode") in ("cron", "cron_stop"): + self._add_cron(job) + self._save_jobs() + return job + + def update_job(self, job_id, **fields): + job = self.jobs.get(job_id) + if not job: + return None + # 更新前先移除旧 cron + was_cron = job.enabled and job.schedule.get("mode") in ("cron", "cron_stop") + if was_cron: + self._remove_cron(job_id) + for k, v in fields.items(): + if v is not None and hasattr(job, k): + setattr(job, k, v) + if job.enabled and job.schedule.get("mode") in ("cron", "cron_stop"): + self._add_cron(job) + self._save_jobs() + return job + + def delete_job(self, job_id): + self._remove_cron(job_id) + if job_id in self.jobs: + del self.jobs[job_id] + self._save_jobs() + return True + return False + + def toggle_job(self, job_id, enabled): + job = self.jobs.get(job_id) + if not job: + return None + job.enabled = enabled + if enabled and job.schedule.get("mode") in ("cron", "cron_stop"): + self._add_cron(job) + else: + self._remove_cron(job_id) + self._save_jobs() + return job + + # ---- APScheduler 集成 ---- + def _cron_id(self, job_id, kind="start"): + """生成 cron job id。kind: start=启动 cron,stop=停止 cron。""" + return f"job_{job_id}_{kind}" + + def _add_cron(self, job): + """注册定时任务。支持三种模式: + - once: 不注册 cron + - cron: 只注册启动 cron(到点启动 worker) + - cron_stop: 注册启动 cron + 停止 cron(到点启动,到点停止) + """ + mode = job.schedule.get("mode", "once") + if mode not in ("cron", "cron_stop"): + return + + start_cron = job.schedule.get("cron", "") + if start_cron: + try: + trigger = CronTrigger.from_crontab(start_cron) + except Exception as e: + _log.error(f"启动 cron 解析失败 {start_cron}: {e}") + else: + self.scheduler.add_job(self._on_cron_trigger, trigger, args=[job.id], + id=self._cron_id(job.id, "start"), + replace_existing=True) + _log.info(f"已注册启动定时 {job.name}({job.id}) cron={start_cron}") + + if mode == "cron_stop": + stop_cron = job.schedule.get("stop_cron", "") + if stop_cron: + try: + trigger = CronTrigger.from_crontab(stop_cron) + except Exception as e: + _log.error(f"停止 cron 解析失败 {stop_cron}: {e}") + else: + self.scheduler.add_job(self._on_cron_stop, trigger, args=[job.id], + id=self._cron_id(job.id, "stop"), + replace_existing=True) + _log.info(f"已注册停止定时 {job.name}({job.id}) stop_cron={stop_cron}") + + def _remove_cron(self, job_id): + """移除该任务的所有 cron job(启动 + 停止)。""" + for kind in ("start", "stop"): + try: + self.scheduler.remove_job(self._cron_id(job_id, kind)) + except Exception: + pass + + def _on_cron_trigger(self, job_id): + """cron 触发:启动该任务的所有目标设备 worker。""" + job = self.jobs.get(job_id) + if not job: + return + _log.info(f"定时触发: {job.name}({job.id})") + self._run_job(job) + + def _on_cron_stop(self, job_id): + """停止 cron 触发:停止该任务正在运行的所有 worker。 + + 只停本 job 启动的 worker,不影响其他任务。 + """ + job = self.jobs.get(job_id) + if not job: + return + with self._lock: + items = [(s, info) for s, info in self._running.items() + if info.get("job_id") == job_id] + stopped = [] + for serial, info in items: + w = info.get("worker") + if w and w.is_alive(): + w.stop() + stopped.append(serial) + _log.info(f"定时停止 {job.name}({job.id}): 停止 {len(stopped)} 台设备 {stopped}") + + def run_job_now(self, job_id): + """立即执行任务(手动触发)。""" + job = self.jobs.get(job_id) + if not job: + return {"ok": False, "error": "任务不存在"} + # 在独立线程跑,不阻塞调用方 + t = threading.Thread(target=self._run_job, args=(job,), daemon=True) + t.start() + return {"ok": True, "msg": f"任务 {job.name} 已触发"} + + def _run_job(self, job): + """执行任务:为每个目标设备起 worker(含重试循环)。""" + serials = job.resolve_serials(self) + if not serials: + _log.warning(f"任务 {job.name} 无可用设备") + return + task_cls = get_task_class(job.task_type) + if not task_cls: + _log.error(f"未知任务类型: {job.task_type}") + return + task = task_cls() + max_attempts = max(1, job.retry.get("max_attempts", 1)) + delay = job.retry.get("delay", 60) + + for serial in serials: + # 每台设备一个重试循环线程,互不影响 + t = threading.Thread(target=self._run_with_retry, + args=(task, serial, job, max_attempts, delay), daemon=True) + t.start() + + def _run_with_retry(self, task, serial, job, max_attempts, delay): + """单设备任务执行 + 重试。 + + 异常分类: + DeviceOfflineError — 设备掉线,立即放弃不重试(换设备也没用) + 其他异常 — 按 max_attempts 重试 + """ + for attempt in range(1, max_attempts + 1): + # 同一 serial 同时只能一个 worker + with self._lock: + if serial in self._running: + _log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})") + return + self._running[serial] = {"job_id": job.id, "started_at": time.time(), + "attempt": attempt, "task_type": job.task_type} + _update_status(serial, task_job=job.name, attempt=attempt, + max_attempts=max_attempts) + + worker = None + try: + worker = task.create_worker(self.stf, serial, job.params) + with self._lock: + self._running[serial]["worker"] = worker + _log.info(f"{serial} 开始任务 {job.name} (第{attempt}/{max_attempts}次)") + worker.start() + worker.join() # 等待 worker 结束 + # worker 正常结束(done 或被 stop) + with self._lock: + self._running.pop(serial, None) + # 判断是否成功:看 status + with _WORKERS_LOCK: + st = _WORKERS.get(serial, {}).get("status") + # 清除 task_job 标记,避免前端误判"运行中" + _update_status(serial, task_job="") + if st == "done": + _log.info(f"{serial} 任务 {job.name} 成功完成") + return + if st == "released" and attempt == 1: + # 被手动停止,不重试 + return + _log.warning(f"{serial} 任务未成功(status={st})") + except DeviceOfflineError as e: + # 设备掉线,立即放弃,不重试 + _log.error(f"{serial} 设备离线,放弃任务 {job.name}: {e}") + with self._lock: + self._running.pop(serial, None) + _update_status(serial, status="failed", task_job="", + last_error=f"设备离线: {e}") + return + except Exception as e: + _log.error(f"{serial} 执行异常: {e}", exc_info=True) + with self._lock: + self._running.pop(serial, None) + _update_status(serial, task_job="") + + if attempt < max_attempts: + # 检查是否是端口耗尽类临时错误,需要更长退避等端口释放 + with _WORKERS_LOCK: + err = _WORKERS.get(serial, {}).get("last_error", "") + if err.startswith("[transient]"): + # Windows TCP 端口耗尽,TIME_WAIT 默认 2-4 分钟,等 120 秒 + extra_delay = max(delay, 120) + _log.info(f"{serial} ADB 连接临时错误(端口耗尽),{extra_delay}s 后重试 ({attempt+1}/{max_attempts})") + time.sleep(extra_delay) + else: + _log.info(f"{serial} {delay}s 后重试 ({attempt+1}/{max_attempts})") + time.sleep(delay) + + _log.error(f"{serial} 任务 {job.name} 重试耗尽,放弃") + _update_status(serial, status="failed", last_error=f"{job.name} 重试{max_attempts}次失败") + + # ---- 运行控制 ---- + def stop_device(self, serial): + """停止指定设备的 worker。""" + with self._lock: + info = self._running.get(serial) + if not info: + return False + w = info.get("worker") + if w and w.is_alive(): + w.stop() + return True + return False + + def stop_all(self): + """停止所有运行中的 worker。""" + with self._lock: + items = list(self._running.items()) + stopped = [] + for serial, info in items: + w = info.get("worker") + if w and w.is_alive(): + w.stop() + stopped.append(serial) + return stopped + + def get_running(self): + with self._lock: + return {s: dict(v) for s, v in self._running.items()} + + # ---- 状态查询(带缓存,避免 STF 请求阻塞前端)---- + _status_cache = None # (timestamp, data, error) + _status_cache_lock = threading.Lock() + _STATUS_CACHE_TTL = 5.0 # 缓存 5 秒,前端 5 秒刷新刚好命中 + + def get_status(self): + """综合状态:STF 设备池 + 本地 worker + 运行中的任务。 + + 带 5 秒缓存:STF 请求慢时避免每次 /api/status 都打 STF 阻塞 Flask。 + worker 状态实时读(内存,无 IO),不受缓存影响。 + """ + with self._status_cache_lock: + if self._status_cache: + ts, cached, err = self._status_cache + if time.time() - ts < self._STATUS_CACHE_TTL: + if err: + return None, err + # 用缓存的设备列表 + 实时 worker 状态重新组装 + return self._merge_status(cached), None + # 缓存过期或不存在,重新拉 STF + try: + all_devices = self.stf.list_all_devices() + except Exception as e: + with self._status_cache_lock: + self._status_cache = (time.time(), None, str(e)) + return None, f"获取设备列表失败: {e}" + with self._status_cache_lock: + self._status_cache = (time.time(), all_devices, None) + return self._merge_status(all_devices), None + + def _merge_status(self, all_devices): + """用 STF 设备列表 + 实时 worker 状态 + 前台 App 组装返回结果。""" + worker_status = {w["serial"]: w for w in get_all_worker_status()} + running = self.get_running() + + result = [] + for dev in all_devices: + serial = dev.get("serial", "") + owner = dev.get("owner") + w = worker_status.get(serial, {}) + r = running.get(serial, {}) + result.append({ + "serial": serial, + "model": dev.get("model") or w.get("model", "") or dev.get("product", ""), + "device_name": dev.get("name") or "", + "present": dev.get("present", False), + "ready": dev.get("ready", False), + "stf_occupied": dev.get("using", False), + "owner": owner.get("name", "") if owner else "", + "worker_status": w.get("status", "idle"), + "foreground_app": self._fg_scanner.get(serial), + # 通用进度字段(任意 app 通用,前端统一解析展示) + # 结构:{"done": int, "total": int, "unit": str, "action_counts": dict} + "progress": w.get("progress", {}), + "current_action": w.get("current_action", ""), + "last_error": w.get("last_error", ""), + "running_job": r.get("job_id", ""), + "task_job": w.get("task_job", ""), + "attempt": r.get("attempt", 0), + }) + return result + + def list_all_serials(self): + """返回 STF 上所有在线设备的 serial 列表(供分组表单勾选用)。 + + 复用 get_status 缓存,避免开页面时阻塞。 + """ + devices, err = self.get_status() + if err or not devices: + return [] + return [d["serial"] for d in devices if d.get("present")] + + def shutdown(self): + self.stop_all() + self._fg_scanner.stop() + stop_watchdog() + self.scheduler.shutdown(wait=False) diff --git a/core/u2_helper.py b/core/u2_helper.py new file mode 100644 index 0000000..77f8a8c --- /dev/null +++ b/core/u2_helper.py @@ -0,0 +1,114 @@ +"""uiautomator2 通用辅助函数。 + +所有任务都可复用:应用启动保障、等待首页、安全点击、随机操作等。 +具体业务逻辑(如抖音首页特征元素判断)不放这里,放各自 task 文件。 +""" +import time +import random + +from core.logger import get_logger + +_log = get_logger("core.u2") + + +def ensure_app_running(d, package, max_restart=3, home_check=None): + """确保 app 在前台运行,被人为退出/崩溃则自动重启。 + + d: u2.Device + package: 包名 + max_restart: 最大重启次数 + home_check: 可选回调 d->bool,判断是否进入主页。 + 默认只看包名(包名对就返回 True,和原抖音逻辑一致)。 + 返回 True 表示 app 在前台;连续重启失败返回 False。 + """ + home_check = home_check or (lambda d: True) + for attempt in range(1, max_restart + 1): + try: + cur = d.app_current() + except Exception: + cur = {} + if cur.get("package") == package and home_check(d): + return True + _log.info(f"{package} 不在前台(当前:{cur}),第 {attempt}/{max_restart} 次重启...") + try: + d.app_start(package, wait=True) + time.sleep(3) + if home_check(d): + return True + except Exception as e: + _log.warning(f"重启异常: {e}") + time.sleep(3) + return False + + +def wait_for_app_home(d, package, home_check, timeout=40): + """等待 app 进入主页。 + + home_check: d->bool 回调,返回 True 表示已进入主页。 + 抖音主页 Activity 名仍叫 SplashActivity,不能靠 Activity 名判断, + 要用界面特征元素(由调用方通过 home_check 传入)。 + """ + deadline = time.time() + timeout + while time.time() < deadline: + try: + cur = d.app_current() + except Exception: + cur = {} + if cur.get("package") == package and home_check(d): + return True + time.sleep(2) + return False + + +def random_sleep(min_s, max_s): + """随机睡眠(模拟人类操作间隔)。""" + time.sleep(random.uniform(min_s, max_s)) + + +def safe_click(el, timeout=1): + """安全点击:元素存在才点,不抛异常。返回是否点击成功。""" + try: + if el.exists(timeout=timeout): + el.click() + return True + except Exception: + pass + return False + + +def safe_set_text(el, text, timeout=1): + """安全输入文本:元素存在才输入,不抛异常。返回是否成功。""" + try: + if el.exists(timeout=timeout): + el.set_text(text) + return True + except Exception: + pass + return False + + +def find_and_click(d, timeout=1, **selectors): + """按定位器找元素并点击,找不到不抛异常。返回是否成功。 + + 用法:find_and_click(d, text="首页") 或 find_and_click(d, description="搜索") + """ + try: + el = d(**selectors) + if el.exists(timeout=timeout): + el.click() + return True + except Exception: + pass + return False + + +def random_swipe_up(d, width_ratio=0.5, height_start=0.8, height_end=0.2, duration=None): + """随机上滑(刷视频常用)。坐标按屏幕比例算,适配不同分辨率。 + + duration: 滑动时长(秒),None 则随机 0.25~0.50s。 + """ + info = d.info + w, h = info["displayWidth"], info["displayHeight"] + dur = duration or random.uniform(0.25, 0.50) + d.swipe(int(w * width_ratio), int(h * height_start), + int(w * width_ratio), int(h * height_end), dur) diff --git a/create_task.py b/create_task.py new file mode 100644 index 0000000..811a3e2 --- /dev/null +++ b/create_task.py @@ -0,0 +1,85 @@ +"""一次性脚本:创建"抖音定时8-9点"任务。 + +用法(在 cmd 中执行,不是 PowerShell): + cd /d d:\\platform-tools + python create_task.py + +任务配置: + - 名称:抖音定时8-9点 + - 类型:douyin_nurture(抖音养号) + - 目标:全部空闲设备 + - 调度:定时启停(每天 8:00 启动,9:00 自动停止) + - 视频数量:不限(watch_count=0,靠停止 cron 到点停) + - 运行时长:不限(max_duration=0,靠停止 cron 到点停) + - 点赞:启用,rate=0.3 +""" +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from flask import Flask +from core.models import db, init_db, TaskJob as JobRow +from core.task_manager import TaskManager, TaskJob +from core.stf_client import STFClient + +app = Flask(__name__) +app.config["SECRET_KEY"] = "dev-secret-key-change-in-production" +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join( + os.path.dirname(os.path.abspath(__file__)), "data", "users.db") +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +init_db(app) + +with app.app_context(): + # 检查是否已存在同名任务 + existing = JobRow.query.filter_by(name="抖音定时8-9点").first() + if existing: + print(f"任务已存在: id={existing.id}, name={existing.name}") + print("如需重新创建,请先在前端删除该任务。") + sys.exit(0) + + # 构造任务计划 + job = TaskJob( + name="抖音定时8-9点", + task_type="douyin_nurture", + target={"mode": "all"}, + params={ + "watch_count": 0, # 不限视频数量 + "max_duration": 0, # 不限运行时长(靠 stop_cron 到点停止) + "actions": { + "like": { + "enabled": True, + "params": {"rate": 0.3, "method": "by_element"}, + }, + }, + }, + schedule={ + "mode": "cron_stop", + "cron": "0 8 * * *", # 每天 8:00 启动 + "stop_cron": "0 9 * * *", # 每天 9:00 停止 + }, + retry={"max_attempts": 1, "delay": 60}, + enabled=True, + ) + + # 写入数据库 + row = JobRow(id=job.id, name=job.name, task_type=job.task_type, enabled=job.enabled) + row.set_target(job.target) + row.set_params(job.params) + row.set_schedule(job.schedule) + row.set_retry(job.retry) + db.session.add(row) + db.session.commit() + + print(f"✓ 任务创建成功!") + print(f" ID: {job.id}") + print(f" 名称: {job.name}") + print(f" 类型: {job.task_type}") + print(f" 目标: 全部空闲设备") + print(f" 调度: 每天 08:00 启动 → 09:00 自动停止") + print(f" 视频数量: 不限(靠定时停止)") + print(f" 点赞: 启用 (rate=0.3)") + print() + print("启动 web_server 后,该任务会自动加载并注册定时。") + print("启动命令: python web_server.py") diff --git a/data/users.db b/data/users.db new file mode 100644 index 0000000000000000000000000000000000000000..0aed34242e2f3544916704e7490af12d0a378dc1 GIT binary patch literal 36864 zcmeI*O>Em#00(fp>Dr`UhDlYlL%@nutzm1hoj4yw1G*8xw0^LXp-l)`PW;k(P3mAf zYfDv$X6!O9y8sfGUeS=)VFHO09MG+vidHc@+wh6S!c4Is7H5vn7DPW+oE(anhWPHx;)%Cr7AI3mLY!YHit}e?XOD_Q z5mvyeUL)-t6jcrGVVF+6s&8n4y<|m7u*2(mc|~6$!AwQ+GA|I@F|E~LrW(vETLuwN z&KC=B78Y57j`?9A-l$hC-OyaK`xLz*-d;R8H?w$7JXJU+PMStyDDiT5AbKLkbLOf+ z)}0Tk^v#T>H(aX~*L)F8_Ct~F^9Nd@7Z0~YUENvH&RZpaI}i}{Qy(JwFQXmI;jXfQf4!8eb!bPRIAEE8?Xwi@;BaDPClJ7-U7uX_Uy0i~Q29C8k^ zO}*M_y+NF^ZPr~nnA%PLXZu*2&&;1ZeWoBzvclIwm>9w zDB@i#7#(|!3yoai#(o_AnQpK^00Izz00bZa0SG_<0uXp&0?k)L!!bFbH6z>Oa}lMK zQ?n^GbNljVcXzJue*MMm&%e!0X>vAdnE zKx-aCmh8RRiEnMj^)j1N&Y_8$@v6DP4%&?GO{Pb~uH7K?=(^74;%nvAh`VeVG#D>! zXrxMNWYuLA*t*LOmXfKhtt}d_>Fb)&psBQYmQIt_bho^$m8}Lnf~L^g{nISlbdp5J zwd$-S7GdQ}VorRC{jo%rsdaMldpWEOT9NL$q*iy?8On5LgV5<3X*eToRr?~biNulT zr{==xv{6y>DP{NjukL=dvwP**?&U8ZpQOldefA+e;_HR?w@ zCPh1`6*E~+vo3Si%sSc2m6B>FrL~8YR9I4Q0Lh&(wO49qA39wiEm-}39hj1~*!};I zaElY}3AcnhPwZ)qiXZ?12tWV=5P$##AOHafKmY;|cq{@Vp&0L9KMS#le>tdcI23DN zo$=;tPqwOYwqPp+4|6g8PiO8tjl{N-0gQZiFgZc0jvQhqumPph)` r{r`LP{{JJL91ey61Rwwb2tWV=5P$##AOHafK;S78VE6z1O9y`g&Ys#M literal 0 HcmV?d00001 diff --git a/doc/TASK_DEV.md b/doc/TASK_DEV.md new file mode 100644 index 0000000..3ecfd9b --- /dev/null +++ b/doc/TASK_DEV.md @@ -0,0 +1,1045 @@ +# 任务开发指南 + +面向 `platform-tools`(STF + uiautomator2 + Flask 单页应用,多设备并发任务执行框架)的新开发者。描述架构、核心概念,并给出从 0 到 1 新增一个 app 任务所需的全部模板与规范。看完本文即可上手开发新任务。 + +--- + +## 1. 架构总览 + +### 1.1 分层设计 + +平台按"配置 / 核心 / 任务 / 前端 / 数据 / 日志 / 工具"分层,职责清晰、互不交叉: + +| 层 | 路径 | 职责 | +| --- | --- | --- | +| 配置层 | `config.py` | 项目根配置:STF 服务地址 / adb 路径 / web 端口等基础设施。**不放任务参数**(任务参数属于 `tasks/`) | +| 核心层 | `core/` | 框架运行时:`logger` 日志、`stf_client` STF API 封装、`adb_helper` adb 操作、`device_worker` Worker 基类、`task_manager` 调度器、`u2_helper` uiautomator2 通用操作、`actions` 全局 Action 基类 | +| 任务层 | `tasks/` | 每个 app 一个子包,自包含 `task.py` + `actions/`,互不依赖 | +| 前端层 | `templates/admin/monitor.html` | 单页应用(纯 HTML+CSS+JS,无框架):设备监控 / 任务管理 / 分组 / 日志 / 用户 5 个 Tab | +| 数据层 | `data/` | SQLite 持久化:`users.db`(用户 + 设备分组 + 任务计划) | +| 日志层 | `logs/` | 四类日志:`core.log` / `task.log` / `web.log` / `action.log`,10MB 滚动保留 5 份 | +| 文档层 | `doc/` | 项目文档 | +| 工具层 | `bin/adb/` | adb 可执行文件 | +| 脚本层 | `scripts/` | 实用脚本 | + +### 1.2 目录树 + +``` +platform-tools/ +├── config.py # 根配置(STF/adb/web,不放任务参数) +├── web_server.py # Flask 入口(JSON API + 登录页 + 单页应用) +├── main.py # 根目录入口 +├── core/ # 核心程序层 +│ ├── __init__.py +│ ├── logger.py # 日志器(分文件、10MB 滚动) +│ ├── stf_client.py # STF API 封装 +│ ├── adb_helper.py # adb 操作(全局锁串行化) +│ ├── device_worker.py # BaseWorker 基类 + STFDevice + 看门狗 +│ ├── task_manager.py # TaskManager 调度器 + 前台 App 扫描器 +│ ├── u2_helper.py # uiautomator2 通用操作(ensure_app_running/wait_for_app_home) +│ ├── models.py # SQLAlchemy 模型(User/DeviceGroup/TaskJob) +│ └── actions/ +│ ├── __init__.py # create_action_registry / register_action / should_trigger +│ └── base.py # BaseAction 全局基类 +├── tasks/ # 任务定义层 +│ ├── __init__.py # 全局 _TASK_TYPES 注册表 +│ ├── base.py # BaseTask 基类 +│ └── douyin/ # 抖音养号(示例) +│ ├── __init__.py +│ ├── task.py # DEFAULT_PARAMS + Worker + Task + @register_task +│ └── actions/ +│ ├── __init__.py # 先 from .base import ACTIONS,再 from . import like, comment +│ ├── base.py # ACTIONS = create_action_registry() +│ ├── like.py # @register_action(ACTIONS) LikeAction +│ └── comment.py # @register_action(ACTIONS) CommentAction +├── templates/admin/ +│ ├── monitor.html # 单页应用(5 Tab,纯前端渲染) +│ └── login.html # 登录页 +├── data/ # 持久化数据 +│ └── users.db # SQLite(用户/分组/任务) +├── logs/ # 日志(10MB 滚动保留 5 份) +├── doc/ # 文档 +├── bin/adb/ # adb 工具 +└── scripts/ # 实用脚本 +``` + +### 1.3 数据流 + +``` +┌──────────────┐ 创建 Job ┌─────────────┐ 分发 ┌──────────────┐ +│ 单页应用前端 │ ───────────► │ TaskManager │ ──────► │ Worker(设备) │ +│ (monitor.html│ └─────────────┘ └──────────────┘ +│ fetch + DOM)│ ▲ │ +└──────────────┘ │ 心跳/状态 │ u2 操作 + │ │ ▼ + │ JSON API │ ┌────────────────┐ + ▼ │ │ STF Device / adb│ +┌──────────────┐ ┌──────────────┐ └────────────────┘ +│ web_server │ │ 看门狗监控 │ +│ (Flask API) │ └──────────────┘ +└──────────────┘ + │ + ▼ +┌──────────────┐ +│ data/users.db│ SQLite 持久化(用户/分组/任务) +└──────────────┘ +``` + +### 1.4 关键设计决策 + +- **Flask + Flask-Login**:已移除 Flask-Admin(自定义场景下过于受限),改用纯 Flask + 单页应用 +- **单页应用**:`web_server.py` 只提供 JSON API + 登录页,`monitor.html` 纯前端渲染(fetch + DOM 操作),无服务端模板依赖 +- **SQLite 持久化**:替代旧 JSON 文件,支持用户/分组/任务的关系存储 +- **多线程模式**:Flask 启用 `threaded=True` 处理并发请求 +- **设备状态缓存**:`get_status` 带 5 秒缓存,worker 运行状态实时组装 + +--- + +## 2. 核心概念 + +### 2.1 TaskType — 任务类型 + +一个 `TaskType` 描述"做什么"(例如抖音养号、快手养号),由 `Task` 子类 + `Worker` 子类 + `DEFAULT_PARAMS` 组成。每个 `TaskType` 注册到全局 `_TASK_TYPES` 字典(在 `tasks/__init__.py`),key 为任务类型字符串,value 为 `Task` 类。 + +```python +# tasks/base.py +_TASK_TYPES = {} + +def register_task(task_cls): + """任务类型注册装饰器(无需传 name,用 task_cls.task_type)""" + _TASK_TYPES[task_cls.task_type] = task_cls + return task_cls + +def get_task_class(task_type): + return _TASK_TYPES.get(task_type) +``` + +### 2.2 TaskJob — 任务计划 + +`TaskJob` 是"什么时候、在哪些设备上、用什么参数执行某个 TaskType"的持久化计划,存于 SQLite(`data/users.db` 的 `task_job` 表)。包含字段: + +- `task_type` — 任务类型(对应 `_TASK_TYPES` 的 key) +- `target` — 目标设备:`{"mode": "all"|"group"|"serial", "group_name": "", "serial": ""}` +- `params` — 任务参数(与 `DEFAULT_PARAMS` 深合并) +- `schedule` — 调度策略:`{"mode": "once"|"cron", "cron": "0 9 * * *"}` +- `retry` — 重试策略:`{"max_attempts": 1, "delay": 60}` +- `enabled` — 是否启用 + +### 2.3 DeviceGroup — 设备分组 + +设备分组存于 SQLite(`device_group` 表),便于按批次/项目/客户分组下发任务。一个 Job 可指定 `target.mode="group"`,调度器展开为组内全部设备序列号。 + +### 2.4 Worker — 单设备执行线程 + +每个被调度的设备对应一个 `Worker` 实例,跑在独立线程中,继承 `BaseWorker`(`core/device_worker.py`)。Worker 负责一台设备的完整生命周期:申请设备 → 连接 u2 → setup → run_task → teardown → 释放设备。 + +### 2.5 Action — 操作 + +`Action` 是任务循环里执行的"原子操作"(点赞 / 评论 / 滑动 / 关注)。每个 app 有**独立的 Action 注册表**(通过 `create_action_registry()` 创建),互不污染。全局基类 `core/actions/base.py::BaseAction` 提供通用能力。 + +### 2.6 进度上报(通用,适配任意 app) + +Worker 通过 `self.set_progress(**fields)` 上报进度,前端统一解析展示。**不再硬编码"已看视频数"等业务字段**。 + +**通用字段**: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `done` | int | 已完成数量 | +| `total` | int | 总数量(0=不限数量,只显示已完成数) | +| `unit` | str | 计数单位("视频"/"轮次"/"条") | +| `action_counts` | dict | 操作计数 `{"like": 3, "comment": 1}` | +| `elapsed` | int | 已运行时长(秒,可选,前端显示为 "Xm Ys") | + +**前端展示**:进度条(百分比,total>0 时)+ "done/total unit" + 运行时长 + 操作计数徽章 + +**示例**: +```python +# 抖音任务(有数量限制) +self.set_progress(done=5, total=80, unit="视频", + action_counts={"like": 3}, elapsed=120) + +# 抖音任务(仅时长限制,无数量) +self.set_progress(done=5, total=0, unit="视频", + action_counts={"like": 3}, elapsed=120) + +# 快手任务 +self.set_progress(done=3, total=20, unit="轮次", + action_counts={"like": 2}, elapsed=60) +``` + +### 2.7 运行时长终止(通用,适配任意 app) + +`BaseWorker` 提供运行时长终止能力,与"数量终止"配合使用。两者**哪个先到就停**。 + +| 成员 | 说明 | +| --- | --- | +| `self.max_duration` | 最大运行时长(秒),0=不限时 | +| `self._start_timer()` | 子类在 `run_task` 开头调用,启动计时 | +| `self.is_time_up()` | 是否已达 max_duration(max_duration=0 永远返回 False) | +| `self.elapsed()` | 已运行时长(秒) | + +**循环条件模板**: +```python +while not self.stopped(): + if watch_count > 0 and watched >= watch_count: + break # 数量终止 + if self.is_time_up(): + break # 时长终止 + # ... 业务逻辑 +``` + +**三种终止模式**: +- 仅数量:`watch_count=80, max_duration=0` → 看完 80 个视频停 +- 仅时长:`watch_count=0, max_duration=1800` → 跑满 30 分钟停 +- 双条件:`watch_count=80, max_duration=1800` → 哪个先到就停 +- 都为 0:永不停止,需手动停止 + +### 2.8 任务调度模式(schedule) + +任务计划 `TaskJob.schedule` 支持三种模式: + +| mode | 字段 | 行为 | +| --- | --- | --- | +| `once` | 无 | 手动执行(前端点"立即执行"或调 `/api/jobs//run`) | +| `cron` | `cron` | 定时启动:到 cron 时间点自动启动 worker | +| `cron_stop` | `cron` + `stop_cron` | 定时启停:启动 cron 到点启动,停止 cron 到点停止本任务的 worker | + +**cron_stop 模式**只停止**本 job 启动的 worker**,不影响其他正在运行的任务。典型用法: +```json +{ + "schedule": { + "mode": "cron_stop", + "cron": "0 9 * * *", + "stop_cron": "0 18 * * *" + } +} +``` +含义:每天 9 点自动启动任务,18 点自动停止。 + +### 2.9 心跳看门狗 + +每个 Worker 在 `set_action` / `set_progress` / `heartbeat` 时更新心跳时间戳。看门狗线程(`_Watchdog`)定期扫描,若超过 `_HEARTBEAT_TIMEOUT=120s` 未更新则判定卡死,标记 error。 + +**长耗时操作必须周期性调用 `self.heartbeat()`**,否则会被误杀。 + +--- + +## 3. 新增一个 app 任务(完整步骤) + +以"快手养号"为例。完整步骤 6 步,全部代码可直接复制。 + +### 步骤 1:在 `tasks/` 下建 `kuaishou/` 子包 + +``` +tasks/kuaishou/ +├── __init__.py +├── task.py +└── actions/ + ├── __init__.py + ├── base.py + └── like.py +``` + +### 步骤 2:写 `actions/base.py`(本任务的注册表) + +```python +# tasks/kuaishou/actions/base.py +"""快手 Action 注册表。""" +from core.actions import ( + BaseAction, register_action, create_action_registry, + list_actions, get_action, should_trigger, +) + +# 快手专属操作注册表(独立 dict,不污染其他 app) +ACTIONS = create_action_registry() + + +def list_action_types(): + """返回所有已注册快手操作的元信息(供前端展示)。""" + return list_actions(ACTIONS) + + +def get_action_class(action_type): + """按 action_type 取快手操作类。""" + return get_action(ACTIONS, action_type) +``` + +### 步骤 3:写 `actions/like.py` + +```python +# tasks/kuaishou/actions/like.py +"""快手点赞 Action。""" +from core.actions import BaseAction, register_action, should_trigger +from core.logger import get_logger +from . import ACTIONS # 必须从 __init__ 导入注册表 + +_log = get_logger("action.kuaishou.like") + + +@register_action(ACTIONS) +class LikeAction(BaseAction): + action_type = "like" + name = "点赞" + description = "看完视频后随机点赞" + default_params = { + "rate": 0.8, # 触发概率 0~1 + "method": "double_tap", # double_tap | heart_icon + } + + def execute(self, d, params, worker): + rate = float(params.get("rate", 0.8)) + if not should_trigger(rate): + return False + method = params.get("method", "double_tap") + try: + if method == "double_tap": + info = d.info + w, h = info["displayWidth"], info["displayHeight"] + d.double_click(int(w * 0.5), int(h * 0.5)) + else: + el = d(description="点赞") + if not el.exists: + _log.info("未找到点赞按钮") + return False + el.click() + _log.info("点赞成功") + return True + except Exception as e: + _log.warning(f"点赞异常: {e}") + return False +``` + +### 步骤 4:写 `actions/__init__.py`(注意循环导入顺序) + +```python +# tasks/kuaishou/actions/__init__.py +"""快手操作注册包。import 触发各操作注册。 + +⚠️ 循环导入坑:必须先从 base 导入 ACTIONS,再导入各操作模块! +""" +from .base import ( + BaseAction, register_action, create_action_registry, + list_actions, get_action, should_trigger, + ACTIONS, list_action_types, get_action_class, +) + +# 再导入各操作模块,触发 @register_action(ACTIONS) 注册 +from . import like # noqa: F401 +# from . import comment # 新增 action 在此 import + +__all__ = [ + "BaseAction", "register_action", "create_action_registry", + "list_actions", "get_action", "should_trigger", + "ACTIONS", "list_action_types", "get_action_class", +] +``` + +### 步骤 5:写 `task.py` + +```python +# tasks/kuaishou/task.py +"""快手养号任务定义。 + +本文件自包含所有快手养号参数,不依赖 core 的业务配置。 +快手专属操作(点赞/评论)在 actions/ 子包里,xpath 只适用于快手。 +""" +import time +import random + +from tasks.base import BaseTask, register_task +from .actions import list_action_types, get_action_class +from core.device_worker import BaseWorker, _update_status +from core.u2_helper import ensure_app_running, wait_for_app_home +from core.logger import get_logger + +_log = get_logger("task.kuaishou") + +KUAISHOU_PKG = "com.smile.gifmaker" + +DEFAULT_PARAMS = { + "watch_count": 50, # 观看视频数量 + "watch_min": 5.0, # 单个视频最短观看秒数 + "watch_max": 30.0, # 单个视频最长观看秒数 + "swipe_min": 0.25, # 上滑手势最短时长(秒) + "swipe_max": 0.50, # 上滑手势最长时长(秒) + "gap_min": 1.0, # 视频间隔最短秒数 + "gap_max": 3.0, # 视频间隔最长秒数 + "actions": { + "like": { + "enabled": True, + "params": {"rate": 0.3, "method": "double_tap"}, + }, + }, +} + + +class KuaishouWorker(BaseWorker): + """快手养号 worker。""" + + def __init__(self, stf_client, serial, params=None): + super().__init__(stf_client, serial, params) + p = {**DEFAULT_PARAMS, **(self.params or {})} + self.watch_count = int(p["watch_count"]) + self.watch_min = float(p["watch_min"]) + self.watch_max = float(p["watch_max"]) + self.swipe_min = float(p["swipe_min"]) + self.swipe_max = float(p["swipe_max"]) + self.gap_min = float(p["gap_min"]) + self.gap_max = float(p["gap_max"]) + self.actions_cfg = p.get("actions", {}) + self._actions = [] + for atype, cfg in self.actions_cfg.items(): + if not cfg.get("enabled"): + continue + cls = get_action_class(atype) + if cls: + self._actions.append(cls()) + if self._actions: + summary = ", ".join( + f"{a.action_type}(rate={self.actions_cfg.get(a.action_type, {}).get('params', {}).get('rate', '?')})" + for a in self._actions + ) + _log.info(f"[{serial}] 启用操作: {summary}") + else: + _log.warning(f"[{serial}] 未启用任何操作") + + def run_task(self, d): + """快手养号主逻辑。d 是 u2.Device,已由基类连好。""" + def is_home(d): + return (d(descriptionContains="首页").exists + or d(descriptionContains="拍摄").exists) + + d.app_start(KUAISHOU_PKG, wait=True) + if not wait_for_app_home(d, KUAISHOU_PKG, is_home, timeout=40): + self.set_action("首页加载超时,继续尝试") + + watched = 0 + action_counts = {a.action_type: 0 for a in self._actions} + # 初始化通用进度上报(前端会解析 done/total/unit + action_counts) + self.set_progress(done=0, total=self.watch_count, unit="视频", + action_counts=action_counts) + + while not self.stopped() and watched < self.watch_count: + if not ensure_app_running(d, KUAISHOU_PKG): + _update_status(self.serial, status="error", + last_error="快手连续重启失败,放弃该设备") + return + + watch = random.uniform(self.watch_min, self.watch_max) + self.set_action(f"观看视频 {watched+1}/{self.watch_count},{watch:.0f}s") + time.sleep(watch) + + # 执行启用的操作 + for action in self._actions: + if self.stopped(): + break + cfg = self.actions_cfg.get(action.action_type, {}) + params = {**action.default_params, **cfg.get("params", {})} + try: + ok = action.execute(d, params, self) + _log.info(f"[{self.serial}] 视频{watched+1}: {action.action_type} execute={ok}") + if ok: + action_counts[action.action_type] += 1 + time.sleep(random.uniform(0.5, 1.5)) + except Exception as e: + _log.error(f"[{self.serial}] 视频{watched+1}: 操作 {action.action_type} 异常: {e}") + + if self.stopped(): + break + d.swipe(500, 1000, 500, 300, random.uniform(self.swipe_min, self.swipe_max)) + time.sleep(random.uniform(self.gap_min, self.gap_max)) + watched += 1 + # 上报通用进度 + self.set_progress(done=watched, total=self.watch_count, unit="视频", + action_counts=dict(action_counts)) + + summary = f"完成 {watched} 个视频" + "".join( + f",{k} {v}次" for k, v in action_counts.items() if v + ) + _update_status(self.serial, current_action=summary) + + +@register_task +class KuaishouTask(BaseTask): + """快手养号任务。""" + task_type = "kuaishou_nurture" + name = "快手养号" + description = "自动观看快手视频,按配置执行点赞等操作" + default_params = dict(DEFAULT_PARAMS) + + @classmethod + def list_action_types(cls): + return list_action_types() + + @classmethod + def get_action_class(cls, action_type): + return get_action_class(action_type) + + def create_worker(self, stf, serial, params): + merged = {**DEFAULT_PARAMS, **(params or {})} + # actions 字段参数级深合并(保留前端没传的操作默认值) + default_actions = DEFAULT_PARAMS["actions"] + merged_actions = params.get("actions", {}) if params else {} + for atype, dflt in default_actions.items(): + if atype not in merged_actions: + merged_actions[atype] = dflt + else: + cfg = merged_actions[atype] + merged_cfg = {} + for k in ("enabled", "params"): + merged_cfg[k] = cfg.get(k, dflt.get(k)) + merged_params = dict(dflt.get("params", {})) + merged_params.update(cfg.get("params", {})) + merged_cfg["params"] = merged_params + merged_actions[atype] = merged_cfg + merged["actions"] = merged_actions + return KuaishouWorker(stf, serial, params=merged) +``` + +### 步骤 6:注册任务包 + +`tasks/kuaishou/__init__.py`: +```python +# tasks/kuaishou/__init__.py +from . import task # noqa: F401 触发 @register_task 注册 +``` + +`tasks/__init__.py` 加一行: +```python +# tasks/__init__.py +from .base import BaseTask, register_task, list_task_types, get_task_class +from . import douyin # noqa: F401 +from . import kuaishou # noqa: F401 ← 新增这一行 +``` + +完成。重启 web 后,前端任务类型下拉自动出现 `kuaishou_nurture`。 + +--- + +## 4. Worker 开发指南 + +`BaseWorker` 位于 `core/device_worker.py`,封装了设备生命周期、心跳、异常分类、与调度器的状态通信。 + +### 4.1 生命周期 + +``` +acquire(serial) # 向 STF 申请设备占用 + │ + ▼ +adb connect + u2.connect # 连接 uiautomator2(带 30s 超时保护) + │ + ▼ +setup(d) # 子类可选钩子(启动 app、授权、关闭弹窗) + │ + ▼ +run_task(d) ◄── 必须实现 # 任务主循环 + │ + ▼ +teardown(d) # 子类可选钩子(退出 app、清理) + │ + ▼ +release(serial) # 释放 STF 占用 +``` + +任意阶段抛出 `DeviceOfflineError` → 立即终止,**不重试**。其他异常 → 按 Job 的 `retry` 策略重试。 + +### 4.2 必须实现 / 可选钩子 + +| 方法 | 是否必须 | 说明 | +| --- | --- | --- | +| `run_task(self, d)` | **必须** | 任务主循环,`d` 为 `uiautomator2.Device` | +| `setup(self, d)` | 可选 | 设备/应用初始化 | +| `teardown(self, d)` | 可选 | 收尾,即使出错也会执行 | +| `on_error(self, d, err)` | 可选 | 异常通知钩子 | + +### 4.3 工具方法 + +| 方法 | 说明 | +| --- | --- | +| `self.stopped()` | **循环里必须检查**,返回 True 表示收到停止信号 | +| `self.set_action(s)` | 设置当前动作(前端大屏"当前动作"列可见),同时刷新心跳 | +| `self.set_progress(**fields)` | 上报进度(见 §2.6),同时刷新心跳 | +| `self.heartbeat()` | 手动刷新心跳(长操作中间调) | +| `self.params` | 已合并 `DEFAULT_PARAMS` 与 Job 参数后的最终参数 | +| `self.serial` | 当前设备序列号 | +| `self.d` | u2.Device(run_task 的 d 参数) | + +### 4.4 进度上报规范(重要) + +**所有 app 任务必须用 `set_progress` 上报进度**,前端会统一解析展示。 + +```python +# ✅ 正确:用通用字段 +self.set_progress(done=5, total=80, unit="视频", + action_counts={"like": 3, "comment": 1}) + +# ❌ 错误:硬编码业务字段(前端无法识别) +self.set_progress(videos_watched=5) # 前端不认这个字段 +``` + +前端展示效果: +- 进度条:`████████░░░░` (按 done/total 算百分比) +- 计数文本:`5/80 视频` +- 操作徽章:`点赞 3` `评论 1` + +### 4.5 异常分类 + +| 异常 | 处理 | +| --- | --- | +| `DeviceOfflineError` | 设备掉线,**不重试**,立即释放 | +| 其他 `Exception` | 按 Job 的 `retry` 次数重试,退避后重新申请设备 | + +### 4.6 `run_task` 模板(可直接复制) + +```python +def run_task(self, d): + """任务主循环模板。""" + # 1. 启动 app + d.app_start("com.xxx", wait=True) + if not wait_for_app_home(d, "com.xxx", lambda d: d(text="首页").exists, timeout=40): + self.set_action("首页加载超时,继续尝试") + + # 2. 初始化进度上报 + watched = 0 + action_counts = {a.action_type: 0 for a in self._actions} + self.set_progress(done=0, total=self.watch_count, unit="视频", + action_counts=action_counts) + + # 3. 主循环 + while not self.stopped() and watched < self.watch_count: + # 3.1 确保 app 在前台 + if not ensure_app_running(d, "com.xxx"): + _update_status(self.serial, status="error", + last_error="app 连续重启失败") + return + + # 3.2 观看 + watch = random.uniform(self.watch_min, self.watch_max) + self.set_action(f"观看 {watched+1}/{self.watch_count},{watch:.0f}s") + time.sleep(watch) + + # 3.3 执行操作 + for action in self._actions: + if self.stopped(): + break + cfg = self.actions_cfg.get(action.action_type, {}) + params = {**action.default_params, **cfg.get("params", {})} + try: + if action.execute(d, params, self): + action_counts[action.action_type] += 1 + except Exception as e: + _log.error(f"[{self.serial}] 操作异常: {e}") + + # 3.4 滑动 + if self.stopped(): + break + d.swipe(500, 1000, 500, 300, 0.3) + time.sleep(random.uniform(1.0, 3.0)) + + # 3.5 上报进度 + watched += 1 + self.set_progress(done=watched, total=self.watch_count, unit="视频", + action_counts=dict(action_counts)) + + # 4. 收尾 + summary = f"完成 {watched} 个视频" + _update_status(self.serial, current_action=summary) +``` + +> **铁律**:循环里必须高频调用 `self.stopped()`,否则停止按钮无响应、看门狗误杀。 + +--- + +## 5. Action 开发指南 + +### 5.1 全局基类与独立注册表 + +- 全局基类:`core/actions/base.py::BaseAction`,提供 `should_trigger` 等通用能力 +- 每个 app 通过 `create_action_registry()` 创建**独立注册表**,避免不同 app 的 `like` / `comment` 同名冲突 +- 注册装饰器:`@register_action(ACTIONS)`,`ACTIONS` 为本 app 的注册表 + +### 5.2 `BaseAction` 关键 API + +| 成员 | 说明 | +| --- | --- | +| `action_type` | 类属性,注册 key,必须与 `params["actions"]` 的 key 一致 | +| `name` | 类属性,中文名(前端展示) | +| `description` | 类属性,描述 | +| `default_params` | 类属性,自包含默认参数 | +| `execute(self, d, params, worker)` | **必须实现**,返回 `True`=成功 / `False`=跳过 | +| `should_trigger(rate)` | 按 `rate` 概率返回是否触发(`rate=0.8` → 80% 概率 True) | + +### 5.3 完整 Action 模板 + +```python +# tasks/xxx/actions/comment.py +import time +import random + +from core.actions import BaseAction, register_action, should_trigger +from core.logger import get_logger +from . import ACTIONS # 从 __init__ 导入本 app 注册表 + +_log = get_logger("action.xxx.comment") + + +@register_action(ACTIONS) +class CommentAction(BaseAction): + action_type = "comment" + name = "评论" + description = "看完视频后随机发一条评论" + default_params = { + "rate": 0.1, + "texts": ["不错", "666", "学到了"], + } + + def execute(self, d, params, worker): + rate = float(params.get("rate", 0.1)) + if not should_trigger(rate): + return False + + texts = params.get("texts") or ["不错"] + text = random.choice(texts) + + # 定位评论按钮(多策略组合,失败回退) + for desc in ("评论", "未评论", "comment"): + el = d(description=desc) + if el.exists: + el.click() + break + else: + _log.info("未找到评论按钮") + return False + + time.sleep(1.5) + + # 找输入框 + for rid in ("com.xxx:id/comment_input",): + el = d(resourceId=rid) + if el.exists: + el.click() + break + else: + d.press("back") + return False + + time.sleep(0.8) + + # 输入文本(中文需切输入法) + try: + d.set_fastinput_ime(True) + except Exception: + pass + d.send_keys(text) + time.sleep(0.6) + + # 发送 + el = d(text="发送") + if el.exists: + el.click() + _log.info(f"评论成功: {text}") + time.sleep(1.0) + d.press("back") + return True + d.press("back") + return False +``` + +> **返回值约定**:`True`=成功执行;`False`=主动跳过(概率未中、元素不存在等);抛异常=执行失败,由 Worker 捕获并记录。 + +--- + +## 6. 参数设计规范 + +### 6.1 自包含 + +`DEFAULT_PARAMS` 放在 `task.py` 顶部,**所有**该任务需要的参数都要列出,包括每个 action 的子参数。不允许"隐式默认值"散落在 action 内部。 + +### 6.2 参数合并(参数级深合并) + +`create_worker` 时执行三层合并: + +```python +def create_worker(self, stf, serial, params): + merged = {**DEFAULT_PARAMS, **(params or {})} + # actions 字段参数级深合并 + default_actions = DEFAULT_PARAMS["actions"] + merged_actions = params.get("actions", {}) if params else {} + for atype, dflt in default_actions.items(): + if atype not in merged_actions: + merged_actions[atype] = dflt + else: + cfg = merged_actions[atype] + merged_cfg = {} + for k in ("enabled", "params"): + merged_cfg[k] = cfg.get(k, dflt.get(k)) + # params 再深合并一层 + merged_params = dict(dflt.get("params", {})) + merged_params.update(cfg.get("params", {})) + merged_cfg["params"] = merged_params + merged_actions[atype] = merged_cfg + merged["actions"] = merged_actions + return MyWorker(stf, serial, params=merged) +``` + +即: +- 顶层字段:Job 参数覆盖默认参数 +- `actions` 字段:**参数级深合并**,前端可只覆盖某个 action 的某个子字段(如只改 `like.rate`) + +### 6.3 前端任务参数 JSON 示例 + +Job 下发时只传**需要覆盖**的字段,调度器做深合并。例如只想把点赞概率从 0.3 调到 0.5,Job params 只需: + +```json +{ + "actions": { + "like": {"params": {"rate": 0.5}} + } +} +``` + +其余字段自动取 `DEFAULT_PARAMS`。**不要**在 Job 里传完整 params——升级默认值时会丢失新字段。 + +--- + +## 7. 日志规范 + +### 7.1 获取 logger + +```python +from core.logger import get_logger + +log = get_logger("task.kuaishou") # 任务日志 → logs/task.log +log = get_logger("action.kuaishou.like") # action 日志 → logs/action.log +log = get_logger("core.task_manager") # 核心日志 → logs/core.log +log = get_logger("web") # web 日志 → logs/web.log +``` + +logger 名前缀决定写入哪个文件: + +| 前缀 | 文件 | +| --- | --- | +| `core.*` | `logs/core.log` | +| `task.*` | `logs/task.log` | +| `action.*` | `logs/action.log` | +| `web.*` | `logs/web.log` | + +### 7.2 级别 + +- `DEBUG` — 详细元素查找、参数 dump(生产关闭) +- `INFO` — 正常流程节点(启动、轮次、action 结果) +- `WARNING` — 可恢复异常(元素找不到、action 失败) +- `ERROR` — 不可恢复错误(设备掉线、调度失败) + +### 7.3 规则 + +- **禁止 `print`**,统一用 `get_logger` +- 日志里带 `[{self.serial}]` 设备前缀,多设备并发时才能区分 +- 单文件 10MB 滚动,保留 5 份历史,无需手动清理 +- 不要在循环里高频打 INFO(如每个 `exists()` 都打),用 DEBUG + +--- + +## 8. STF 设备调试 + +### 8.1 常见错误 + +| 现象 | 原因 | 处理 | +| --- | --- | --- | +| HTTP 504 | 设备掉线 / STF 卡住 | 抛 `DeviceOfflineError`,不重试 | +| `DeviceOfflineError` | u2 连不上 / adb 远程不通 | 立即释放,跳过该设备 | +| `present=True` 但操作失败 | STF 状态有缓存,`present` 不代表真在线 | 用前台 App 扫描复测 | +| u2.connect 永久 hang | atx-agent 无响应 | 基类已加 30s 超时保护,超时抛异常 | + +### 8.2 前台 App 扫描(不打扰设备) + +Web 提供"扫描前台App"按钮(`/api/scan_foreground`),按设备状态分三类处理: + +| 设备状态 | 处理方式 | 是否打扰 | +| --- | --- | --- | +| worker 运行中 | 复用已有 ADB 连接查询 | 否 | +| 完全空闲 | `adb connect` → `dumpsys` → `adb disconnect` | 否 | +| 他人占用 | 标记"(他人占用)" | 否 | + +**绝不使用 STF occupy/release**——会唤醒 STF agent 导致设备退回桌面。 + +### 8.3 不重试原则 + +`DeviceOfflineError` 一律不重试——设备掉线后短时间内不会自愈,重试只会浪费配额并阻塞调度器。让设备进入冷却,由运维/STF 恢复后再启用。 + +--- + +## 9. 定位元素技巧 + +### 9.1 抓界面 + +用 [weditor](https://github.com/alibaba/web-editor)(`pip install weditor` → `python -m weditor`)实时查看 UI 树,复制定位表达式。 + +### 9.2 定位优先级 + +``` +description > descriptionContains > resourceId > text/textContains > xpath +``` + +- **`description`** 最稳,开发者较少改动 contentDescription +- **`descriptionContains`** 模糊匹配,适配不同版本文案(如"点赞"/"未点赞") +- **`resourceId`** 注意带包名前缀(`com.xxx:id/...`),跨版本可能变,建议**多候选** +- **`xpath`** 用**相对定位**,禁止依赖 `FrameLayout[2]` / `LinearLayout[3]` 这类绝对序号 + +### 9.3 多策略组合 + 回退 + +```python +def find_like_button(d): + """多策略定位点赞按钮,失败回退。""" + # 1. description 精确 + for desc in ("点赞", "未点赞", "like"): + el = d(description=desc) + if el.exists: + return el + # 2. descriptionContains 模糊 + for kw in ("赞", "like"): + el = d(descriptionContains=kw) + if el.exists: + return el + # 3. resourceId 列表(多候选) + for rid in ("com.xxx:id/aky", "com.xxx:id/d-like-view-icon"): + el = d(resourceId=rid) + if el.exists: + return el + return None +``` + +### 9.4 xpath 写法 + +```python +# ✅ 相对定位,稳 +d.xpath('//android.widget.TextView[@text="关注"]').click() + +# ❌ 绝对序号,UI 一变就崩 +d.xpath('//FrameLayout[2]/LinearLayout[1]/TextView[3]').click() +``` + +--- + +## 10. 常见问题 + +### 10.1 循环导入 + +`actions/__init__.py` 必须**先导入 `base` 再导入各 action 模块**: + +```python +# tasks/xxx/actions/__init__.py +from .base import ACTIONS, ... # 1. 先建注册表 +from . import like, comment # 2. 再导入各 action,触发 @register_action +``` + +`tasks/__init__.py` 同理:先 `from .base import BaseTask`,再 `from . import douyin`。 + +### 10.2 中文输入 + +uiautomator2 默认 IME 不支持中文。需切到 fastinput: + +```python +try: + d.set_fastinput_ime(True) # 切入 + d.send_keys("中文内容") +finally: + try: + d.set_fastinput_ime(False) # 用完切回 + except Exception: + pass +``` + +设备未装 FastInput 输入法时 `set_fastinput_ime` 会静默失败,建议加 try/except + 日志。 + +### 10.3 多设备并发 + +`adb_helper` 内置**全局锁**串行化所有 adb 调用(`adb connect` / `adb devices` 等)。原因: + +- 多线程并发调 adb 会触发 adb server 竞争,导致连接抖动 +- **禁止**在任务代码里调 `adb kill-server`——会踢掉所有设备的连接 +- 设备申请/释放走 `stf_client`,与 adb 锁配合避免冲突 + +```python +# ✅ 正确:用 adb_helper 封装 +from core.adb_helper import adb_connect +adb_connect(serial) + +# ❌ 错误:自己起 subprocess 调 adb,绕过全局锁 +import subprocess +subprocess.run(["adb", "connect", serial]) + +# ❌ 严禁 +subprocess.run(["adb", "kill-server"]) +``` + +### 10.4 看门狗误杀 + +若任务有长耗时操作(如长视频播放等待 5 分钟),看门狗可能误判卡死。解决: + +- 在长操作内部**周期性调用 `self.heartbeat()`**(如每 30 秒一次),而不是只在整个操作前后调 +- 不要调高看门狗阈值——真卡死的设备需要尽快释放 + +```python +# 长等待的正确写法 +end = time.time() + 300 +while time.time() < end: + if self.stopped(): + return + self.heartbeat() # 长循环内部也要心跳 + time.sleep(5) +``` + +### 10.5 u2.connect 卡死 + +`u2.connect()` 在 atx-agent 无响应时会永久 hang。基类已用 `ThreadPoolExecutor + future.result(timeout=30)` 包裹,超时返回 None 并抛异常。**子类无需处理**,但要避免在 run_task 里直接调 `u2.connect()`。 + +### 10.6 任务参数前端覆盖 + +Job 下发时只传**需要覆盖**的字段,调度器做深合并(见 §6.2)。例如只想把点赞概率从 0.8 调到 0.5,Job params 只需: + +```json +{ + "actions": { + "like": {"params": {"rate": 0.5}} + } +} +``` + +其余字段自动取 `DEFAULT_PARAMS`。**不要**在 Job 里传完整 params——升级默认值时会丢失新字段。 + +### 10.7 进度上报必须用通用字段 + +前端只认 `progress = {done, total, unit, action_counts}` 结构。**不要**用 `videos_watched`、`round_idx` 等业务字段名——前端不会识别。 + +```python +# ✅ 正确 +self.set_progress(done=5, total=80, unit="视频", + action_counts={"like": 3}) + +# ❌ 错误(前端不认) +self.set_progress(videos_watched=5, round_idx=3) +``` + +--- + +## 附录:新增任务 Checklist + +新建一个 app 任务时,按此清单逐项确认: + +- [ ] `tasks//__init__.py` 有 `from . import task` +- [ ] `tasks//task.py` 有 `DEFAULT_PARAMS`(自包含)+ `Worker(BaseWorker)` + `Task(BaseTask)` + `@register_task` +- [ ] `Worker.run_task` 已实现,循环顶部和 action 之间都检查 `self.stopped()` +- [ ] `Worker.run_task` 用 `self.set_progress(done=, total=, unit=, action_counts=)` 上报进度 +- [ ] 长循环内周期性调用 `self.heartbeat()` +- [ ] `tasks//actions/__init__.py` 先 `from .base import ACTIONS` 再导入各 action +- [ ] 每个 Action 有 `action_type` / `name` / `default_params` / `execute`,返回 `True/False` +- [ ] `Task.create_worker` 做 actions 参数级深合并(照抄抖音模板) +- [ ] `tasks/__init__.py` 已 `from . import ` 注册 +- [ ] 日志用 `get_logger("task.")` / `get_logger("action..")`,无 `print` +- [ ] 定位元素优先 `description` / `descriptionContains`,resourceId 多候选,xpath 用相对定位 +- [ ] 中文输入用 `set_fastinput_ime`,加 try/except +- [ ] adb 操作走 `adb_helper`,未自起 subprocess,未 `kill-server` + +完成上述清单后,重启 web,前端单页应用即可看到新任务类型并可下发。 diff --git a/main.py b/main.py new file mode 100644 index 0000000..115d65b --- /dev/null +++ b/main.py @@ -0,0 +1,43 @@ +"""抖音养号命令行入口。 + +直接运行:python main.py + - 单设备跑一次养号,不走 web/调度 + - 任务参数默认值在 tasks/douyin_nurture.py 顶部,可通过环境变量 WATCH_COUNT 覆盖视频数 + +web 管理后台请运行:python web_server.py +清理 STF 残留占用:python scripts/cleanup.py + +目录结构: + config.py — 项目根配置(STF 地址/token、adb 路径、web 端口、数据目录) + bin/adb/ — adb.exe 及依赖 dll + core/ — 核心程序(STF 客户端、adb 工具、BaseWorker 基类、任务管理器) + tasks/ — 任务定义(每个任务一个 .py,自带参数默认值,继承 BaseTask + @register_task) + web/ — 前端页面(index.html) + data/ — 持久化数据(分组、任务计划) + scripts/ — 实用脚本 +""" +import os + +from core.stf_client import STFClient +from tasks import get_task_class + + +def run_once(serial=None, **overrides): + """单设备跑一次养号(命令行入口,不启动 web)。 + + 走任务框架:取 douyin_nurture 任务类 → create_worker → 直接 run(当前线程)。 + overrides 可传任务参数覆盖默认值,如 run_once(watch_count=20, like_enabled=False)。 + """ + stf = STFClient() + task_cls = get_task_class("douyin_nurture") + task = task_cls() + params = dict(overrides) + # 命令行友好:环境变量 WATCH_COUNT 覆盖视频数 + if "WATCH_COUNT" in os.environ and "watch_count" not in params: + params["watch_count"] = int(os.environ["WATCH_COUNT"]) + worker = task.create_worker(stf, serial, params) + worker.run() # 直接在当前线程跑,不起子线程 + + +if __name__ == "__main__": + run_once() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..49af3b3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,25 @@ +# 设备自动化后台 依赖清单 +# 安装:pip install -r requirements.txt +# +# 注意:uiautomator2 依赖 adb,本项目的 bin/adb/ 已自带 adb.exe, +# 无需单独安装 Android SDK Platform Tools。 +# uiautomator2 首次连接设备时会自动推送 atx-agent 到设备。 + +# Web 框架 +Flask>=2.3,<4.0 + +# Flask 扩展 +Flask-Login>=0.6 +Flask-SQLAlchemy>=3.0,<4.0 + +# 定时任务调度 +APScheduler>=3.10,<4.0 + +# HTTP 客户端(STF API 调用) +requests>=2.28 + +# Android UI 自动化(uiautomator2) +uiautomator2>=3.0 + +# APK 元信息解析(应用管理功能:自动读取包名/版本/应用名) +pyaxmlparser>=0.3.27 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..dd66cb6 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""scripts 包:实用脚本(清理、批量操作等)。每个脚本可独立运行。""" diff --git a/scripts/cleanup.py b/scripts/cleanup.py new file mode 100644 index 0000000..c63a26e --- /dev/null +++ b/scripts/cleanup.py @@ -0,0 +1,49 @@ +"""清理 STF 上残留的设备占用(强杀进程导致没正常释放时用)。 + +运行:python scripts/cleanup.py + (在 auto/ 目录下运行也可:python -m scripts.cleanup) +""" +import os +import sys + +# 让脚本既能直接运行(python scripts/cleanup.py)又能作为模块运行。 +# scripts/ 的上一级 auto/ 是 core 包的父目录,需加入 sys.path。 +_AUTO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _AUTO_DIR not in sys.path: + sys.path.insert(0, _AUTO_DIR) + +import requests +from config import STF_URL, STF_TOKEN + +headers = {"Authorization": f"Bearer {STF_TOKEN}"} + +# /user/devices 返回当前用户正在占用的设备 +resp = requests.get(f"{STF_URL}/api/v1/user/devices", headers=headers) +resp.raise_for_status() +data = resp.json() +mine = data.get("devices", []) +print(f"当前账户占用的设备数: {len(mine)}") +for d in mine: + print(f" - {d.get('serial')} using={d.get('using')}") + +# 全部释放 +for d in mine: + serial = d["serial"] + try: + requests.post( + f"{STF_URL}/api/v1/user/devices/{serial}/remoteDisconnect", + headers=headers, + ) + except Exception: + pass + r = requests.delete(f"{STF_URL}/api/v1/user/devices/{serial}", headers=headers) + print(f"释放 {serial}: HTTP {r.status_code}") + +# 再看一次总设备池 +resp = requests.get(f"{STF_URL}/api/v1/devices", headers=headers) +resp.raise_for_status() +free = [d for d in resp.json().get("devices", []) + if d.get("present") and d.get("ready") and not d.get("using") and d.get("owner") is None] +print(f"\n清理后空闲设备数: {len(free)}") +for d in free: + print(f" - {d.get('serial')}") diff --git a/scripts/fix_web.bat b/scripts/fix_web.bat new file mode 100644 index 0000000..d38d28c --- /dev/null +++ b/scripts/fix_web.bat @@ -0,0 +1,57 @@ +@echo off +title Fix Web Access + +:: Auto elevate to admin +net session >nul 2>&1 +if %errorLevel% neq 0 ( + echo Requesting admin privileges... + powershell -Command "Start-Process cmd -ArgumentList '/c ""%~f0""' -Verb RunAs" + exit /b +) + +echo ============================================ +echo Fix Web Backend Access +echo ============================================ +echo. + +:: 1. Disable residual system proxy (blocks localhost) +echo [1/4] Disabling system proxy... +reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings" /v ProxyEnable /t REG_DWORD /d 0 /f >nul 2>&1 +reg delete "HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings" /v ProxyServer /f >nul 2>&1 +echo Done. +echo. + +:: 2. Add firewall rules for Python +echo [2/4] Adding firewall rules for Python... +for /f "delims=" %%i in ('where python') do set PYTHON_PATH=%%i +if not defined PYTHON_PATH ( + echo ERROR: Python not found in PATH + pause + exit /b 1 +) +echo Python path: %PYTHON_PATH% +netsh advfirewall firewall delete rule name="PythonWeb" >nul 2>&1 +netsh advfirewall firewall add rule name="PythonWeb" dir=in action=allow program="%PYTHON_PATH%" enable=yes profile=any >nul +netsh advfirewall firewall delete rule name="PythonWebOut" >nul 2>&1 +netsh advfirewall firewall add rule name="PythonWebOut" dir=out action=allow program="%PYTHON_PATH%" enable=yes profile=any >nul +echo Done. +echo. + +:: 3. Flush DNS cache +echo [3/4] Flushing DNS cache... +ipconfig /flushdns >nul 2>&1 +echo Done. +echo. + +:: 4. Show excluded port ranges for reference +echo [4/4] TCP excluded port ranges (for reference): +netsh interface ipv4 show excludedportrange protocol=tcp +echo. + +echo ============================================ +echo Fix complete! Now: +echo 1. Close ALL browser windows and reopen +echo 2. Run: python web_server.py +echo 3. Open: http://127.0.0.1:5050/ +echo ============================================ +pause diff --git a/scripts/pack.py b/scripts/pack.py new file mode 100644 index 0000000..cdf21cd --- /dev/null +++ b/scripts/pack.py @@ -0,0 +1,67 @@ +"""打包脚本:把项目代码打成 zip 压缩包,排除运行时产物。 + +用法:python scripts/pack.py +输出:项目根目录下 platform-tools.zip +""" +import os +import sys +import zipfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OUT = os.path.join(os.path.dirname(ROOT), "platform-tools.zip") + +SKIP_DIRS = {"__pycache__", ".git", ".idea", "venv", ".venv", "node_modules"} +SKIP_EXTS = {".pyc", ".pyo"} +# 路径片段包含这些的跳过 +SKIP_PARTS = {"__pycache__"} +# 文件名完全匹配这些的跳过 +SKIP_FILES = {"fix_web.bat"} +# 文件名后缀匹配这些的跳过 +SKIP_SUFFIX = (".migrated",) +# 特定目录下特定后缀跳过 +# logs/ 下的 .log +# data/apks/ 下的 .apk + + +def should_skip(path, name): + full = os.path.join(path, name) + rel = os.path.relpath(full, ROOT) + norm = rel.replace("\\", "/") + if "__pycache__" in norm: + return True + if name in SKIP_FILES: + return True + if name.endswith(SKIP_SUFFIX): + return True + ext = os.path.splitext(name)[1] + if ext in SKIP_EXTS: + return True + if name.endswith(".log") and "/logs/" in norm: + return True + if name.endswith(".apk") and "/data/apks/" in norm: + return True + return False + + +def main(): + if os.path.exists(OUT): + os.remove(OUT) + count = 0 + with zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED) as z: + for dp, dn, fn in os.walk(ROOT): + # 原地修改 dn 跳过目录 + dn[:] = [d for d in dn if d not in SKIP_DIRS] + for f in fn: + if should_skip(dp, f): + continue + full = os.path.join(dp, f) + arc = os.path.relpath(full, os.path.dirname(ROOT)) + z.write(full, arc) + count += 1 + size_kb = os.path.getsize(OUT) // 1024 + print(f"打包完成: {OUT}") + print(f"文件数: {count}, 大小: {size_kb} KB ({size_kb/1024:.1f} MB)") + + +if __name__ == "__main__": + main() diff --git a/start_web.bat b/start_web.bat new file mode 100644 index 0000000..fe9ef1c --- /dev/null +++ b/start_web.bat @@ -0,0 +1,34 @@ +@echo off +chcp 65001 >nul 2>&1 +title 设备自动化后台(局域网模式) + +REM ===== 自动提权到管理员 ===== +net session >nul 2>&1 +if %errorlevel% neq 0 ( + echo 需要管理员权限以支持局域网访问,正在提权... + powershell -Command "Start-Process -FilePath '%~f0' -Verb RunAs" + exit /b +) + +cd /d "%~dp0" + +REM ===== 添加防火墙入站规则(放行 Python,仅一次) ===== +for /f "delims=" %%i in ('where python') do set PY_EXE=%%i +netsh advfirewall firewall show rule name="PlatformTools Web" >nul 2>&1 +if %errorlevel% neq 0 ( + echo 正在添加防火墙规则... + netsh advfirewall firewall add rule name="PlatformTools Web" dir=in action=allow program="%PY_EXE%" enable=yes >nul 2>&1 + netsh advfirewall firewall add rule name="PlatformTools Web" dir=out action=allow program="%PY_EXE%" enable=yes >nul 2>&1 +) + +echo ============================================ +echo 管理后台已启动(局域网可访问) +echo 本机访问: http://127.0.0.1:5050/ +echo 局域网访问: http://本机IP:5050/ +echo 默认账号: admin / admin123 +echo 按 Ctrl+C 停止 +echo ============================================ +echo. + +python web_server.py +pause diff --git a/static/admin/custom.css b/static/admin/custom.css new file mode 100644 index 0000000..22f5414 --- /dev/null +++ b/static/admin/custom.css @@ -0,0 +1,55 @@ +:root{ + --primary:#3b82f6; --primary-dark:#2563eb; --success:#10b981; --danger:#ef4444; + --bg:#f8fafc; --card:#ffffff; --border:#e2e8f0; --text:#1e293b; --text-muted:#64748b; +} +body{background:var(--bg);font-family:-apple-system,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;color:var(--text)} +/* 导航栏:深色渐变 */ +.navbar{background:linear-gradient(135deg,#1e293b 0%,#334155 100%)!important;border:none!important;box-shadow:0 2px 8px rgba(0,0,0,.15);border-radius:0} +.navbar-brand,.navbar-nav>li>a,.navbar-nav>li>a>.nav-link{color:#cbd5e1!important;font-weight:500;font-size:14px} +.navbar-brand{font-weight:700!important;font-size:16px!important} +.navbar-nav>li>a:hover,.navbar-brand:hover{color:#fff!important;background:rgba(255,255,255,.08)!important} +.navbar-nav>li.active>a{background:rgba(59,130,246,.25)!important;color:#fff!important} +/* 内容容器 */ +.container-fluid{max-width:1600px;padding:24px 28px} +/* 卡片面板 */ +.panel{border:1px solid var(--border)!important;border-radius:12px!important;box-shadow:0 1px 3px rgba(0,0,0,.06)!important;background:var(--card)} +.panel-heading{background:#f8fafc!important;border-bottom:1px solid var(--border)!important;font-weight:600;color:var(--text)!important;border-radius:12px 12px 0 0!important} +/* 表格 */ +.table{background:var(--card);border-radius:8px;overflow:hidden;border:1px solid var(--border)} +.table thead th{background:#f1f5f9!important;border-bottom:2px solid var(--border)!important;font-weight:600!important;color:var(--text-muted)!important;font-size:12px!important;text-transform:uppercase;letter-spacing:.5px;padding:12px 14px!important} +.table tbody td{padding:10px 14px!important;border-bottom:1px solid var(--border)!important;vertical-align:middle!important;font-size:13px!important} +.table tbody tr:hover td{background:#f8fafc!important} +/* 按钮 */ +.btn{border-radius:8px!important;font-weight:500!important;border:1px solid var(--border)!important;padding:7px 16px!important;font-size:13px!important;transition:all .15s!important} +.btn-primary{background:var(--primary)!important;border-color:var(--primary)!important;color:#fff!important} +.btn-primary:hover{background:var(--primary-dark)!important} +.btn-danger{background:var(--danger)!important;border-color:var(--danger)!important;color:#fff!important} +.btn-danger:hover{background:#dc2626!important} +.btn-default{background:var(--card)!important;color:var(--text)!important} +.btn-default:hover{background:#f1f5f9!important;border-color:#cbd5e1!important} +/* 表单 */ +.form-control{border-radius:8px!important;border:1px solid var(--border)!important;padding:7px 12px!important;font-size:13px!important} +.form-control:focus{border-color:var(--primary)!important;box-shadow:0 0 0 3px rgba(59,130,246,.1)!important} +/* 徽章 */ +.label,.badge{padding:4px 10px!important;border-radius:6px!important;font-size:11px!important;font-weight:600!important} +.label-success,.badge-success{background:#dcfce7!important;color:#166534!important} +.label-default,.badge-default{background:#f1f5f9!important;color:#64748b!important} +.label-warning,.badge-warning{background:#fef3c7!important;color:#92400e!important} +.label-danger,.badge-danger{background:#fee2e2!important;color:#991b1b!important} +/* 分页/操作菜单 */ +.pagination{margin:16px 0 0} +.pagination>li>a,.pagination>li>span{border-radius:6px!important;margin:0 2px;border:1px solid var(--border)!important;color:var(--text-muted)!important} +.pagination>li.active>a,.pagination>li.active>span{background:var(--primary)!important;border-color:var(--primary)!important;color:#fff!important} +.dropdown-menu{border-radius:8px!important;border:1px solid var(--border)!important;box-shadow:0 4px 12px rgba(0,0,0,.08)!important} +/* 首页链接 */ +.list-group-item{border:1px solid var(--border)!important} +a{color:var(--primary)} +a:hover{color:var(--primary-dark)} +/* 页面标题 */ +h4.mb-4{font-weight:700!important;color:var(--text)!important;font-size:18px!important} +/* 警告框 */ +.alert{border-radius:8px!important;border:1px solid var(--border)!important} +.alert-info{background:#dbeafe!important;border-color:#bfdbfe!important;color:#1e40af!important} +.alert-danger{background:#fee2e2!important;border-color:#fecaca!important;color:#991b1b!important} +/* 选中行高亮 */ +.table tbody tr.selected td{background:#dbeafe!important} diff --git a/tasks/__init__.py b/tasks/__init__.py new file mode 100644 index 0000000..c28d2cd --- /dev/null +++ b/tasks/__init__.py @@ -0,0 +1,12 @@ +"""tasks 包:任务定义层。 + +每个任务一个独立子包(文件夹),自包含任务定义 + 专属操作 + 参数。 +新增任务:在此目录新建 xxx/ 子包,定义 BaseTask 子类并用 @register_task 装饰, +然后在这里 import(from .xxx import task)。 +""" +from .base import BaseTask, register_task, list_task_types, get_task_class + +# 导入所有任务包,触发 @register_task 注册 +from .douyin import task # noqa: F401 + +__all__ = ["BaseTask", "register_task", "list_task_types", "get_task_class"] diff --git a/tasks/base.py b/tasks/base.py new file mode 100644 index 0000000..a2a325e --- /dev/null +++ b/tasks/base.py @@ -0,0 +1,120 @@ +"""任务类型基类与注册机制。 + +目录约定: + config.py — 项目根配置(STF/adb/web 等,核心程序用,不放任务参数) + core/ — 核心程序(STF、adb、BaseWorker、任务管理器) + tasks/ — 任务包,每个任务一个子文件夹,自包含任务+专属操作+参数 + tasks// + __init__.py — import task 触发注册 + task.py — BaseTask 子类 + Worker + DEFAULT_PARAMS + actions/ — 该任务专属操作(xpath 等只适用于本任务,不污染全局) + __init__.py + base.py — 本任务的操作基类 + 注册器 + xxx.py — 具体操作 + +新增任务步骤(照着 tasks/douyin/ 抄即可): + + 1. 在 tasks/ 下新建 my_task/ 子包(文件夹 + __init__.py) + 2. 在 my_task/task.py 顶部写默认参数: + + DEFAULT_PARAMS = {"loop": 10, "actions": {...}} + + 3. 写一个 BaseWorker 子类,实现 run_task(d): + + from core.device_worker import BaseWorker, _update_status + + class MyWorker(BaseWorker): + def run_task(self, d): # d 是 u2.Device,基类已连好 + d.app_start("com.xxx", wait=True) + while not self.stopped(): + self.set_action("干活中") + self.set_progress(done=1) + + 4. 写一个 BaseTask 子类,用 @register_task 装饰,实现 create_worker: + + @register_task + class MyTask(BaseTask): + task_type = "my_task" + name = "我的任务" + default_params = dict(DEFAULT_PARAMS) + + # 有专属操作时重写这两个方法,从 actions 包取 + @classmethod + def list_action_types(cls): + from .actions import list_action_types + return list_action_types() + + @classmethod + def get_action_class(cls, action_type): + from .actions import get_action_class + return get_action_class(action_type) + + def create_worker(self, stf, serial, params): + return MyWorker(stf, serial, params=params) + + 5. 在 my_task/__init__.py 加:from . import task (触发注册) + 6. 在 tasks/__init__.py 加:from .my_task import task (触发注册) + + 做完前端自动出现新任务。有专属操作时前端"操作"区自动列出。 + + BaseWorker 已封装好的(子类不用管): + - STF 占用/释放(try/finally 保证释放) + - adb 连接 / u2.connect + - 状态上报(status/current_action/last_error/model/remote_adb_url) + - 异常捕获(run_task 里抛异常 → status=error,设备仍会释放) + - stop 信号(self.stopped() 检查) + + 子类可用的工具: + self.stopped() — 是否被要求停止 + self.set_action(s) — 上报当前动作 + self.set_progress() — 上报进度字段 + self.d — u2.Device + self.params — 任务参数 dict + core.u2_helper — 通用 u2 辅助函数(ensure_app_running/safe_click 等) +""" + + +class BaseTask: + """任务类型基类。子类实现 create_worker。 + + 有专属操作的任务重写 list_action_types / get_action_class, + 从自己的 actions 子包取(操作 xpath 等只适用于本任务)。 + """ + task_type = "base" + name = "基础任务" + description = "" + default_params = {} + + def create_worker(self, stf, serial, params): + """返回一个 threading.Thread(已启动或待启动),执行实际任务。""" + raise NotImplementedError + + @classmethod + def list_action_types(cls): + """返回本任务支持的操作列表。默认无操作,子类按需重写。""" + return [] + + @classmethod + def get_action_class(cls, action_type): + """按 action_type 取操作类。默认无操作,子类按需重写。""" + return None + + +_TASK_TYPES = {} + + +def register_task(task_cls): + """注册新任务类型。用作装饰器。""" + _TASK_TYPES[task_cls.task_type] = task_cls + return task_cls + + +def list_task_types(): + """返回所有已注册任务类型的元信息(供前端展示)。""" + return [{"task_type": t.task_type, "name": t.name, "description": t.description, + "default_params": t.default_params} for t in _TASK_TYPES.values()] + + +def get_task_class(task_type): + """按 task_type 取任务类。""" + return _TASK_TYPES.get(task_type) diff --git a/tasks/douyin/__init__.py b/tasks/douyin/__init__.py new file mode 100644 index 0000000..eed26bc --- /dev/null +++ b/tasks/douyin/__init__.py @@ -0,0 +1,9 @@ +"""抖音养号任务包。 + +本包自包含:任务定义(task.py)+ 抖音专属操作(actions/)。 +抖音的点赞/评论 xpath 只适用于抖音,不放全局,避免和其他任务混淆。 + +加新抖音操作:在 actions/ 下建 .py,继承本包 BaseAction + @register_action, +在 actions/__init__.py import。 +""" +from . import task # noqa: F401 触发 @register_task 注册抖音任务 diff --git a/tasks/douyin/actions/__init__.py b/tasks/douyin/actions/__init__.py new file mode 100644 index 0000000..7d9dd48 --- /dev/null +++ b/tasks/douyin/actions/__init__.py @@ -0,0 +1,21 @@ +"""抖音操作注册包。import 触发各操作注册。 + +加新抖音操作:新建 xxx.py,在下面加一行 from . import xxx。 +注意:必须先从 base 导入 ACTIONS,再导入各操作模块,否则循环导入。 +""" +# 先初始化注册表(base.py 里定义了 ACTIONS = create_action_registry()) +from .base import ( + BaseAction, register_action, create_action_registry, + list_actions, get_action, should_trigger, + ACTIONS, + list_action_types, get_action_class, +) + +# 再导入各操作模块,触发 @register_action(ACTIONS) 注册 +from . import like # noqa: F401 + +__all__ = [ + "BaseAction", "register_action", "create_action_registry", + "list_actions", "get_action", "should_trigger", + "ACTIONS", "list_action_types", "get_action_class", +] diff --git a/tasks/douyin/actions/base.py b/tasks/douyin/actions/base.py new file mode 100644 index 0000000..905e3f5 --- /dev/null +++ b/tasks/douyin/actions/base.py @@ -0,0 +1,51 @@ +"""抖音操作基类与注册机制。 + +操作 = 抖音养号主循环里某个时机执行的一次具体行为(点赞/评论/关注等)。 +这里的注册表只对抖音任务生效,其他任务有自己的 actions 包。 + +设计:抖音的 ACTIONS 注册表是独立的 dict,用 core.actions 的 +register_action(ACTIONS) 装饰器注册,和全局/其他 app 互不污染。 + +新增抖音操作步骤(照着 like.py 抄): + + 1. 在 tasks/douyin/actions/ 下新建 my_action.py + 2. 写一个 BaseAction 子类,用 @register_action 装饰,实现 execute: + + from core.actions import BaseAction, register_action, should_trigger + from . import ACTIONS # 本任务注册表 + + @register_action(ACTIONS) + class MyAction(BaseAction): + action_type = "my_action" + name = "我的操作" + description = "做什么" + default_params = {"rate": 0.5} + + def execute(self, d, params, worker): + if not should_trigger(params["rate"]): + return False + # ... 用 d(xpath=...) / d(text=...) 操作 + return True + + 3. 在 tasks/douyin/actions/__init__.py 加一行:from . import my_action + + 做完前端自动出现该操作的可勾选项。 +""" +from core.actions import ( + BaseAction, register_action, create_action_registry, + list_actions, get_action, should_trigger, +) + +# 抖音专属操作注册表(独立 dict,不污染其他 app) +ACTIONS = create_action_registry() + + +# 兼容旧导入:tasks/douyin/actions/__init__.py 原来从这里导出 +def list_action_types(): + """返回所有已注册抖音操作的元信息(供前端展示)。""" + return list_actions(ACTIONS) + + +def get_action_class(action_type): + """按 action_type 取抖音操作类。""" + return get_action(ACTIONS, action_type) diff --git a/tasks/douyin/actions/like.py b/tasks/douyin/actions/like.py new file mode 100644 index 0000000..a349e26 --- /dev/null +++ b/tasks/douyin/actions/like.py @@ -0,0 +1,105 @@ +"""抖音点赞操作。 + +两种方式: + by_element — 用 xpath 找右侧红心按钮点击(相对定位,不依赖视频卡片序号) + double_click — 双击屏幕中央(兜底,不依赖元素,抖音"双击点赞"手势) + +定位器随抖音版本变,by_element 找不到会自动回退双击,不会卡住。 +要适配新版本抖音:用 weditor 重新抓当前界面,调整下面定位逻辑。 +""" +from core.actions import BaseAction, register_action, should_trigger +from core.logger import get_logger +from . import ACTIONS + +_log = get_logger("action.like") + + +@register_action(ACTIONS) +class LikeAction(BaseAction): + action_type = "like" + name = "点赞" + description = "看完视频后随机点赞(找红心按钮,找不到回退双击)" + default_params = { + "rate": 0.3, # 触发概率 0~1 + "method": "by_element", # by_element=找红心 | double_click=双击屏幕 + } + + def execute(self, d, params, worker): + rate = float(params.get("rate", 0.3)) + if not should_trigger(rate): + return False + method = params.get("method", "by_element") + if method == "by_element": + if self._like_by_element(d): + worker.set_action("点赞(红心)") + return True + # 找不到红心,回退双击 + ok = self._double_click_center(d) + if ok: + worker.set_action("点赞(双击)") + return ok + + @staticmethod + def _like_by_element(d): + """找右侧操作栏红心按钮点击,成功返回 True。 + + 定位优先级:description 精确 → descriptionContains 模糊 → resourceId → xpath + 每种方式找到即点即返回,找不到继续下一种。全程记日志。 + """ + # 方式1:description 精确匹配(最稳,不依赖结构) + for desc in ("点赞", "未点赞", "like"): + el = d(description=desc) + if el.exists: + el.click() + _log.info(f"like 命中 description={desc}") + return True + # 方式2:descriptionContains 模糊匹配(适配不同版本文案) + for kw in ("赞", "like"): + el = d(descriptionContains=kw) + if el.exists: + el.click() + _log.info(f"like 命中 descriptionContains={kw}") + return True + # 方式3:resourceId 列表(扩充候选,覆盖更多抖音版本) + for rid in ("com.ss.android.ugc.aweme:id/aky", + "com.ss.android.ugc.aweme:id/d-like-view-icon", + "com.ss.android.ugc.aweme:id/h3r", + "com.ss.android.ugc.aweme:id/dfz", + "com.ss.android.ugc.aweme:id/c5w"): + el = d(resourceId=rid) + if el.exists: + el.click() + _log.info(f"like 命中 resourceId={rid}") + return True + # 方式4:xpath 相对定位——右侧操作栏 LinearLayout 的第一个 ImageView + # 定位思路:ViewPager 下当前 FrameLayout 里的 LinearLayout, + # 取其直接子 FrameLayout 里的第一个 ImageView(点赞在操作栏最上方) + try: + el = d.xpath( + '//androidx.viewpager.widget.ViewPager' + '/android.widget.FrameLayout' + '/com.bytedance.highperformanceview.layout.MeasureOnceRelativeLayout2' + '/com.bytedance.highperformanceview.layout.MeasureOnceRelativeLayout2' + '/android.widget.LinearLayout/android.widget.FrameLayout[2]' + '/android.widget.LinearLayout/android.widget.FrameLayout[1]' + '/android.widget.FrameLayout[1]/android.widget.ImageView' + ) + if el.exists: + el.click() + _log.info("like 命中 xpath") + return True + except Exception as e: + _log.warning(f"like xpath 异常: {e}") + _log.info("like_by_element 全部失败,准备回退双击") + return False + + @staticmethod + def _double_click_center(d): + """双击屏幕中央点赞。按屏幕尺寸算坐标,适配不同分辨率。""" + try: + info = d.info + w, h = info["displayWidth"], info["displayHeight"] + d.double_click(int(w * 0.5), int(h * 0.5)) + return True + except Exception: + return False diff --git a/tasks/douyin/task.py b/tasks/douyin/task.py new file mode 100644 index 0000000..b667324 --- /dev/null +++ b/tasks/douyin/task.py @@ -0,0 +1,269 @@ +"""抖音养号任务定义。 + +本文件自包含所有抖音养号参数(观看、滑动、操作等),不依赖 core 的业务配置。 +抖音专属操作(点赞/评论)在 actions/ 子包里,xpath 只适用于抖音。 + +修改养号行为:改下面 DEFAULT_PARAMS 默认值,或通过前端任务参数覆盖。 +修改养号逻辑:改 DouyinNurtureWorker.run_task。 +加新抖音操作:在 actions/ 下建 .py,继承 BaseAction + @register_action。 +""" +import time +import random + +from tasks.base import BaseTask, register_task +from .actions import list_action_types, get_action_class +from core.device_worker import BaseWorker, _update_status +from core.u2_helper import ensure_app_running, wait_for_app_home +from core.logger import get_logger + +_log = get_logger("task.douyin") + + +# ================== 抖音养号参数(本任务专属,写在这里) ================== +DOUYIN_PKG = "com.ss.android.ugc.aweme" + +DEFAULT_PARAMS = { + # 观看 + "watch_count": 80, # 观看视频数量(0=不限数量,仅按 max_duration 终止) + "watch_min": 5.0, # 单个视频最短观看秒数 + "watch_max": 35.0, # 单个视频最长观看秒数 + # 运行时长终止(通用,0=不限时,按 watch_count 终止) + # 两个条件哪个先到就停;都为 0 则永不停止(需手动停止) + "max_duration": 0, # 最大运行时长(秒),如 1800=30分钟,0=不限时 + # 滑动 + "swipe_min": 0.25, # 上滑手势最短时长(秒) + "swipe_max": 0.50, # 上滑手势最长时长(秒) + "gap_min": 1.0, # 视频间隔最短秒数 + "gap_max": 3.0, # 视频间隔最长秒数 + # 操作(每个操作有 enabled 开关 + 自身参数) + "actions": { + "like": { + "enabled": True, + "params": {"rate": 0.3, "method": "by_element"}, + }, + }, +} + + +class DouyinNurtureWorker(BaseWorker): + """抖音养号 worker:看视频 + 按配置执行操作(点赞/评论等),被退出自动重连。 + + 只需实现 run_task(d),STF 占用/释放、u2 连接、异常、状态上报由基类处理。 + 操作参数优先级:任务 params.actions > 本文件 DEFAULT_PARAMS.actions。 + """ + + def __init__(self, stf_client, serial, params=None): + super().__init__(stf_client, serial, params) + p = {**DEFAULT_PARAMS, **(self.params or {})} + self.watch_count = int(p["watch_count"]) + self.watch_min = float(p["watch_min"]) + self.watch_max = float(p["watch_max"]) + # 通用运行时长终止(0=不限时) + self.max_duration = int(p.get("max_duration", 0)) + self.swipe_min = float(p["swipe_min"]) + self.swipe_max = float(p["swipe_max"]) + self.gap_min = float(p["gap_min"]) + self.gap_max = float(p["gap_max"]) + # 操作配置:{action_type: {"enabled":bool, "params":{...}}} + self.actions_cfg = p.get("actions", {}) + # 实例化所有启用的操作 + self._actions = [] + for atype, cfg in self.actions_cfg.items(): + if not cfg.get("enabled"): + continue + cls = get_action_class(atype) + if cls: + self._actions.append(cls()) + # 启动日志:打印终止条件 + 启用的 action,便于排查 + stop_conds = [] + if self.watch_count > 0: + stop_conds.append(f"数量={self.watch_count}") + if self.max_duration > 0: + stop_conds.append(f"时长={self.max_duration}s") + if not stop_conds: + stop_conds.append("无(需手动停止)") + _log.info(f"[{serial}] 终止条件: {', '.join(stop_conds)}") + if self._actions: + summary = ", ".join( + f"{a.action_type}(rate={self.actions_cfg.get(a.action_type, {}).get('params', {}).get('rate', '?')})" + for a in self._actions + ) + _log.info(f"[{serial}] 启用操作: {summary}") + else: + _log.warning(f"[{serial}] 未启用任何操作,整个任务期间不会执行点赞") + + def run_task(self, d): + """抖音养号主逻辑。d 是 u2.Device,已由基类连好。 + + 终止条件(哪个先到就停): + - watch_count > 0:看完指定数量视频 + - max_duration > 0:达到最大运行时长 + - 两者都为 0:永不停止,需手动停止 + """ + def is_home(d): + return (d(descriptionContains="拍摄").exists + or d(descriptionContains="首页").exists) + + # 启动运行时长计时(基类通用方法) + self._start_timer() + + d.app_start(DOUYIN_PKG, wait=True) + if not wait_for_app_home(d, DOUYIN_PKG, is_home, timeout=40): + self.set_action("首页加载超时,继续尝试") + + watched = 0 + action_counts = {a.action_type: 0 for a in self._actions} + # 初始化通用进度上报 + # total=0 时前端不显示百分比,只显示已看数量 + 运行时长 + total = self.watch_count if self.watch_count > 0 else 0 + self.set_progress(done=0, total=total, unit="视频", + action_counts=action_counts) + while not self.stopped(): + # 终止条件检查 + if self.watch_count > 0 and watched >= self.watch_count: + break + if self.is_time_up(): + _log.info(f"[{self.serial}] 达到最大运行时长 {self.max_duration}s,停止") + break + + if not ensure_app_running(d, DOUYIN_PKG): + _update_status(self.serial, status="error", + last_error="抖音连续重启失败,放弃该设备") + return + _update_status(self.serial, douyin_running=True, last_error="") + + watch = random.uniform(self.watch_min, self.watch_max) + # 动作描述包含运行时长,便于前端观察 + elapsed = self.elapsed() + time_info = f",已运行 {elapsed//60}m{elapsed%60}s" if elapsed > 0 else "" + self.set_action(f"观看视频 {watched+1},{watch:.0f}s{time_info}") + time.sleep(watch) + + # 检测当前是否为可操作的正常视频页(有右侧操作栏 = 点赞/评论按钮) + # 营销广告、直播、异常页通常没有这些元素,直接滑过去不做操作 + is_normal = self._is_normal_video(d) + if is_normal: + # 正常视频:执行所有启用的操作(看完视频后、滑动前) + if not self._actions: + _log.info(f"[{self.serial}] 视频{watched+1}: 正常视频但无启用操作") + for action in self._actions: + if self.stopped(): + break + cfg = self.actions_cfg.get(action.action_type, {}) + params = {**action.default_params, **cfg.get("params", {})} + rate = params.get("rate", "?") + try: + ok = action.execute(d, params, self) + _log.info(f"[{self.serial}] 视频{watched+1}: {action.action_type} " + f"rate={rate} execute={ok}") + if ok: + action_counts[action.action_type] += 1 + time.sleep(random.uniform(0.5, 1.5)) + except Exception as e: + _log.error(f"[{self.serial}] 视频{watched+1}: 操作 {action.action_type} " + f"异常: {e}") + else: + self.set_action(f"跳过广告/直播 {watched+1}") + _log.info(f"[{self.serial}] 视频{watched+1}: 非正常视频页,跳过操作") + + if self.stopped(): + break + d.swipe(500, 1000, 500, 300, random.uniform(self.swipe_min, self.swipe_max)) + time.sleep(random.uniform(self.gap_min, self.gap_max)) + watched += 1 + # 上报通用进度(含运行时长,前端展示) + elapsed = self.elapsed() + self.set_progress(done=watched, total=total, unit="视频", + action_counts=dict(action_counts), + elapsed=elapsed) + + # 终止原因汇总 + stop_reason = "" + if self.stopped(): + stop_reason = "(手动停止)" + elif self.is_time_up(): + stop_reason = f"(达到时长 {self.max_duration}s)" + elif self.watch_count > 0 and watched >= self.watch_count: + stop_reason = f"(达到数量 {self.watch_count})" + summary = f"完成 {watched} 个视频" + "".join( + f",{k} {v}次" for k, v in action_counts.items() if v + ) + stop_reason + _update_status(self.serial, douyin_running=False, current_action=summary) + + @staticmethod + def _is_normal_video(d): + """判断当前是否为可操作的正常视频页。 + + 正常视频页右侧有操作栏(点赞/评论/分享按钮)。 + 营销广告、直播、异常页通常没有这些元素 → 返回 False,直接滑过去。 + 检测任一特征元素存在即认为是正常视频页。 + + 定位优先级:description 精确 → descriptionContains 模糊 → resourceId 列表 → 兜底 + 每次返回 False 时记日志,便于定位"为什么操作没执行"。 + """ + try: + # 1. description 精确匹配(最稳) + for desc in ("点赞", "未点赞", "评论", "未评论"): + if d(description=desc).exists: + return True + # 2. descriptionContains 模糊匹配(适配不同版本文案) + for kw in ("赞", "评论"): + if d(descriptionContains=kw).exists: + return True + # 3. resourceId 列表(扩充候选,覆盖更多抖音版本) + for rid in ("com.ss.android.ugc.aweme:id/aky", + "com.ss.android.ugc.aweme:id/akq", + "com.ss.android.ugc.aweme:id/d-like-view-icon", + "com.ss.android.ugc.aweme:id/d-comment-view-icon", + "com.ss.android.ugc.aweme:id/h3r"): + if d(resourceId=rid).exists: + return True + # 所有特征元素都没找到 → 非正常视频页(广告/直播/异常) + _log.info(f"_is_normal_video=False: 未找到点赞/评论特征元素") + return False + except Exception as e: + _log.warning(f"_is_normal_video 异常: {e}") + return False + + +@register_task +class DouyinNurtureTask(BaseTask): + """抖音养号:看视频 + 按配置执行操作(点赞/评论等),被退出自动重连。""" + task_type = "douyin_nurture" + name = "抖音养号" + description = "自动观看抖音视频,按配置执行点赞/评论等操作,被退出自动重连" + default_params = dict(DEFAULT_PARAMS) + + @classmethod + def list_action_types(cls): + """返回本任务支持的抖音操作列表。""" + return list_action_types() + + @classmethod + def get_action_class(cls, action_type): + """按 action_type 取抖音操作类。""" + return get_action_class(action_type) + + def create_worker(self, stf, serial, params): + merged = {**DEFAULT_PARAMS, **(params or {})} + # actions 字段深合并(参数级,保留前端没传的操作默认值) + # 确保即使前端只传 actions.like.enabled,like.params.rate 也能拿到默认 0.3 + default_actions = DEFAULT_PARAMS["actions"] + merged_actions = params.get("actions", {}) if params else {} + for atype, dflt in default_actions.items(): + if atype not in merged_actions: + # 前端完全没传这个操作 → 用默认 + merged_actions[atype] = dflt + else: + # 前端传了 → 参数级深合并 + cfg = merged_actions[atype] + merged_cfg = {} + for k in ("enabled", "params"): + merged_cfg[k] = cfg.get(k, dflt.get(k)) + # params 再深合并一层 + merged_params = dict(dflt.get("params", {})) + merged_params.update(cfg.get("params", {})) + merged_cfg["params"] = merged_params + merged_actions[atype] = merged_cfg + merged["actions"] = merged_actions + return DouyinNurtureWorker(stf, serial, params=merged) diff --git a/templates/admin/login.html b/templates/admin/login.html new file mode 100644 index 0000000..a79d812 --- /dev/null +++ b/templates/admin/login.html @@ -0,0 +1,42 @@ + + + + + +登录 · 设备自动化后台 + + + +
+ +

设备自动化后台

+
Automation Management System
+ {% if error %}
{{ error }}
{% endif %} +
+ + + +
+
默认账号 admin / admin123,登录后请及时修改密码
+
+ + diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html new file mode 100644 index 0000000..1f07832 --- /dev/null +++ b/templates/admin/monitor.html @@ -0,0 +1,1104 @@ + + + + + +设备自动化后台 + + + + + + + +
+ + +
+
设备状态监控
+
实时展示所有设备状态、当前执行任务及进度
+ +
+ +
设备列表
+
+ + + + + · 每 5 秒自动刷新 · +
+ + + + + + +
设备型号STF任务状态前台App进度当前任务当前动作最近错误操作
+ +
任务运行概况
+
+
+ + +
+
任务计划管理
+
新建、编辑、删除任务计划,支持立即执行和定时调度
+
+ + +
+ + + + + +
ID任务名类型启用目标调度重试操作
+
+ + +
+
应用管理
+
上传 APK 文件,批量安装到设备(运行中设备自动跳过)
+
+ + + +
+ + + + + +
应用名包名版本大小上传时间操作
+
+ + +
+
设备分组管理
+
管理设备分组,用于任务按分组执行
+
+ + +
+ + + + + +
分组名描述设备数设备列表操作
+
+ + +
+
日志查看
+
查看系统各模块运行日志
+
+ + + + + +
+
+
+ + +
+
用户管理
+
管理后台登录用户
+
+ + +
+ + + + + +
ID用户名管理员操作
+
+ +
+ + + + + +
+ + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..22d9b16 --- /dev/null +++ b/web/index.html @@ -0,0 +1,94 @@ + +设备监控大屏 + +
+

设备监控大屏

+ + + 管理后台 → +
+
+
+
+ + + + + 每 5 秒自动刷新 · 分组/任务管理请到 管理后台 +
+ + + +
设备型号STF任务状态抖音进度任务名称当前动作操作计数最近错误操作
+
+ + diff --git a/web_server.py b/web_server.py new file mode 100644 index 0000000..052ffd9 --- /dev/null +++ b/web_server.py @@ -0,0 +1,426 @@ +"""设备自动化管理后台(Flask + Flask-Login,单页应用)。 + +启动:python web_server.py +访问:http://localhost:5000 + +页面结构: + / — 单页应用(监控/任务/分组/日志/用户 Tab 切换,需登录) + /login /logout — 用户登录/登出 + /api/* — JSON API + +用户系统: + 首次启动自动创建默认管理员 admin/admin123(请及时改密码)。 + 用户数据存 data/users.db(SQLite)。 +""" +import os +import time + +from flask import Flask, jsonify, request, redirect, url_for, render_template +from flask_login import (LoginManager, login_user, logout_user, + login_required, current_user) + +from core.stf_client import STFClient +from core.task_manager import TaskManager +from core.logger import get_logger, _LOG_DIR, _MODULE_FILES +from core.models import db, init_db, User, DeviceGroup, TaskJob +from core.apk_manager import ApkManager +from core.adb_helper import identify_device +from tasks import list_task_types, get_task_class + +_log = get_logger("web") + +app = Flask(__name__) +app.config["SECRET_KEY"] = "dev-secret-key-change-in-production" +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join( + os.path.dirname(os.path.abspath(__file__)), "data", "users.db") +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +login_manager = LoginManager(app) +login_manager.login_view = "login" + +# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB) +init_db(app) +stf = STFClient() +mgr = TaskManager(stf, app=app) +apk_mgr = ApkManager(stf, app=app) + + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) + + +# ================== 页面路由 ================== +@app.route("/") +@login_required +def index(): + """单页应用首页。""" + return render_template("admin/monitor.html") + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "") + password = request.form.get("password", "") + user = User.query.filter_by(username=username).first() + if user and user.check_password(password): + login_user(user) + _log.info(f"用户 {username} 登录") + return redirect(request.args.get("next") or url_for("index")) + return render_template("admin/login.html", error="用户名或密码错误") + return render_template("admin/login.html", error=None) + + +@app.route("/logout") +@login_required +def logout(): + _log.info(f"用户 {current_user.username} 登出") + logout_user() + return redirect(url_for("login")) + + +# ================== API:状态(监控大屏用)================== +@app.route("/api/status") +@login_required +def api_status(): + devices, err = mgr.get_status() + if err: + return jsonify({"ok": False, "error": err}), 500 + return jsonify({ + "ok": True, "devices": devices, "server_time": time.time(), + "fg_scanning": mgr._fg_scanner.is_scanning, + "fg_last_scan": mgr._fg_scanner.last_scan_time, + }) + + +@app.route("/api/scan_foreground", methods=["POST"]) +@login_required +def api_scan_foreground(): + """手动触发前台 App 扫描(不打扰设备)。""" + started = mgr._fg_scanner.scan_once() + if started: + return jsonify({"ok": True, "msg": "扫描已启动"}) + return jsonify({"ok": False, "error": "已有扫描在进行中"}) + + +@app.route("/api/task_types") +@login_required +def api_task_types(): + return jsonify({"ok": True, "task_types": list_task_types()}) + + +@app.route("/api/actions") +@login_required +def api_actions(): + """返回指定任务类型支持的专属操作。""" + task_type = request.args.get("task_type", "") + task_cls = get_task_class(task_type) + if not task_cls: + return jsonify({"ok": False, "error": "未知任务类型", "actions": []}) + actions = task_cls.list_action_types() + return jsonify({"ok": True, "actions": actions}) + + +@app.route("/api/devices") +@login_required +def api_devices(): + """返回所有在线设备 serial(供分组表单勾选用)。""" + try: + return jsonify({"ok": True, "devices": mgr.list_all_serials()}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + +# ================== API:任务计划 CRUD ================== +@app.route("/api/jobs") +@login_required +def api_jobs_list(): + return jsonify({"ok": True, "jobs": [j.to_dict() for j in mgr.jobs.values()], + "task_types": list_task_types()}) + + +@app.route("/api/jobs", methods=["POST"]) +@login_required +def api_jobs_create(): + data = request.json or {} + name = (data.get("name") or "").strip() + if not name: + return jsonify({"ok": False, "error": "任务名不能为空"}), 400 + task_type = data.get("task_type", "douyin_nurture") + if not get_task_class(task_type): + return jsonify({"ok": False, "error": f"未知任务类型: {task_type}"}), 400 + job = mgr.add_job( + name=name, task_type=task_type, + target=data.get("target", {"mode": "all"}), + params=data.get("params", {}), + schedule=data.get("schedule", {"mode": "once"}), + retry=data.get("retry", {"max_attempts": 1, "delay": 60}), + enabled=data.get("enabled", True)) + return jsonify({"ok": True, "msg": "任务已创建", "job": job.to_dict()}) + + +@app.route("/api/jobs/", methods=["PUT"]) +@login_required +def api_jobs_update(job_id): + job = mgr.jobs.get(job_id) + if not job: + return jsonify({"ok": False, "error": "任务不存在"}), 404 + data = request.json or {} + fields = {} + for k in ("name", "task_type", "target", "params", "schedule", "retry", "enabled"): + if k in data: + fields[k] = data[k] + if "task_type" in fields and not get_task_class(fields["task_type"]): + return jsonify({"ok": False, "error": f"未知任务类型: {fields['task_type']}"}), 400 + mgr.update_job(job_id, **fields) + return jsonify({"ok": True, "msg": "任务已更新", "job": job.to_dict()}) + + +@app.route("/api/jobs/", methods=["DELETE"]) +@login_required +def api_jobs_delete(job_id): + if mgr.delete_job(job_id): + return jsonify({"ok": True, "msg": "任务已删除"}) + return jsonify({"ok": False, "error": "任务不存在"}), 404 + + +@app.route("/api/jobs//run", methods=["POST"]) +@login_required +def api_jobs_run(job_id): + return jsonify(mgr.run_job_now(job_id)) + + +@app.route("/api/jobs//toggle", methods=["POST"]) +@login_required +def api_jobs_toggle(job_id): + enabled = (request.json or {}).get("enabled", True) + job = mgr.toggle_job(job_id, enabled) + if not job: + return jsonify({"ok": False, "error": "任务不存在"}), 404 + return jsonify({"ok": True, "msg": f"任务已{'启用' if enabled else '停用'}"}) + + +# ================== API:设备分组 CRUD ================== +@app.route("/api/groups") +@login_required +def api_groups_list(): + return jsonify({"ok": True, "groups": [g.to_dict() for g in mgr.groups.values()]}) + + +@app.route("/api/groups", methods=["POST"]) +@login_required +def api_groups_create(): + data = request.json or {} + name = (data.get("name") or "").strip() + if not name: + return jsonify({"ok": False, "error": "分组名不能为空"}), 400 + if name in mgr.groups: + return jsonify({"ok": False, "error": "分组名已存在"}), 400 + mgr.add_group(name, data.get("serials", []), data.get("description", "")) + return jsonify({"ok": True, "msg": "分组已创建"}) + + +@app.route("/api/groups/", methods=["PUT"]) +@login_required +def api_groups_update(name): + if name not in mgr.groups: + return jsonify({"ok": False, "error": "分组不存在"}), 404 + data = request.json or {} + mgr.update_group(name, + serials=data.get("serials"), + description=data.get("description")) + return jsonify({"ok": True, "msg": "分组已更新"}) + + +@app.route("/api/groups/", methods=["DELETE"]) +@login_required +def api_groups_delete(name): + if mgr.delete_group(name): + return jsonify({"ok": True, "msg": "分组已删除"}) + return jsonify({"ok": False, "error": "分组不存在"}), 404 + + +# ================== API:用户管理 CRUD ================== +@app.route("/api/users") +@login_required +def api_users_list(): + users = [{"id": u.id, "username": u.username, "is_admin": u.is_admin} for u in User.query.all()] + return jsonify({"ok": True, "users": users}) + + +@app.route("/api/users", methods=["POST"]) +@login_required +def api_users_create(): + data = request.json or {} + username = (data.get("username") or "").strip() + password = data.get("password", "") + if not username or not password: + return jsonify({"ok": False, "error": "用户名和密码不能为空"}), 400 + if User.query.filter_by(username=username).first(): + return jsonify({"ok": False, "error": "用户名已存在"}), 400 + u = User(username=username, is_admin=data.get("is_admin", True)) + u.set_password(password) + db.session.add(u) + db.session.commit() + _log.info(f"创建用户 {username}") + return jsonify({"ok": True, "msg": "用户已创建"}) + + +@app.route("/api/users/", methods=["PUT"]) +@login_required +def api_users_update(uid): + u = User.query.get(uid) + if not u: + return jsonify({"ok": False, "error": "用户不存在"}), 404 + data = request.json or {} + if "password" in data and data["password"]: + u.set_password(data["password"]) + if "is_admin" in data: + u.is_admin = bool(data["is_admin"]) + db.session.commit() + _log.info(f"更新用户 {u.username}") + return jsonify({"ok": True, "msg": "用户已更新"}) + + +@app.route("/api/users/", methods=["DELETE"]) +@login_required +def api_users_delete(uid): + u = User.query.get(uid) + if not u: + return jsonify({"ok": False, "error": "用户不存在"}), 404 + if u.username == "admin": + return jsonify({"ok": False, "error": "不能删除默认管理员"}), 400 + if u.id == current_user.id: + return jsonify({"ok": False, "error": "不能删除当前登录用户"}), 400 + db.session.delete(u) + db.session.commit() + _log.info(f"删除用户 {u.username}") + return jsonify({"ok": True, "msg": "用户已删除"}) + + +# ================== API:日志查看 ================== +@app.route("/api/logs") +@login_required +def api_logs(): + files = list(_MODULE_FILES.values()) + current = request.args.get("file", "core.log") + lines = int(request.args.get("lines", 300)) + content = "" + path = os.path.join(_LOG_DIR, current) + if os.path.exists(path): + try: + with open(path, encoding="utf-8") as f: + content = "".join(f.readlines()[-lines:]) + except Exception as e: + content = f"读取失败: {e}" + return jsonify({"ok": True, "content": content, "file": current, "files": files}) + + +# ================== API:运行控制 ================== +@app.route("/api/stop_device", methods=["POST"]) +@login_required +def api_stop_device(): + serial = (request.json or {}).get("serial", "") + if mgr.stop_device(serial): + return jsonify({"ok": True, "msg": f"已发送停止信号给 {serial}"}) + return jsonify({"ok": False, "error": f"{serial} 没有运行中的任务"}), 400 + + +@app.route("/api/stop_all", methods=["POST"]) +@login_required +def api_stop_all(): + stopped = mgr.stop_all() + return jsonify({"ok": True, "stopped": stopped}) + + +@app.route("/api/release", methods=["POST"]) +@login_required +def api_release(): + released = stf.release_all_mine() + return jsonify({"ok": True, "released": released}) + + +@app.route("/api/device/identify", methods=["POST"]) +@login_required +def api_device_identify(): + """让设备响铃以供识别(直连 serial,不经过 STF)。""" + serial = (request.json or {}).get("serial", "") + if not serial: + return jsonify({"ok": False, "error": "缺少 serial"}), 400 + ok, msg = identify_device(serial) + if ok: + return jsonify({"ok": True, "msg": msg}) + return jsonify({"ok": False, "error": msg}), 500 + + +# ================== API:应用管理(APK 上传/安装)================== +@app.route("/api/apks") +@login_required +def api_apks_list(): + """列出所有已上传的 APK。""" + return jsonify({"ok": True, "apks": apk_mgr.list_all()}) + + +@app.route("/api/apks/upload", methods=["POST"]) +@login_required +def api_apks_upload(): + """上传 APK 文件(multipart/form-data,字段名 file)。""" + file = request.files.get("file") + if not file or not file.filename: + return jsonify({"ok": False, "error": "未选择文件"}), 400 + info = apk_mgr.upload(file) + if info: + return jsonify({"ok": True, "apk": info, + "msg": f"上传成功: {info['display_name']}"}) + return jsonify({"ok": False, "error": "上传失败,请检查文件格式"}), 500 + + +@app.route("/api/apks/", methods=["DELETE"]) +@login_required +def api_apks_delete(apk_id): + """删除 APK 文件和记录。""" + ok, msg = apk_mgr.delete(apk_id) + if ok: + return jsonify({"ok": True, "msg": msg}) + return jsonify({"ok": False, "error": msg}), 400 + + +@app.route("/api/apks/install", methods=["POST"]) +@login_required +def api_apks_install(): + """批量安装 APK 到指定设备。参数: {apk_id, serials:[]}""" + data = request.json or {} + apk_id = data.get("apk_id", "") + serials = data.get("serials", []) + ok, msg = apk_mgr.install(apk_id, serials) + if ok: + return jsonify({"ok": True, "msg": msg}) + return jsonify({"ok": False, "error": msg}), 400 + + +@app.route("/api/apks/install/status") +@login_required +def api_apks_install_status(): + """获取安装任务实时状态。""" + status = apk_mgr.get_install_status() + return jsonify({"ok": True, "status": status}) + + +if __name__ == "__main__": + from config import WEB_HOST, WEB_PORT + _log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)") + try: + try: + app.run(host=WEB_HOST, port=WEB_PORT, debug=False, threaded=True) + except OSError as e: + # 0.0.0.0 在 Windows 非管理员下会触发 WinError 10013 + # 降级到 127.0.0.1 保证本机至少能访问,局域网访问需用管理员启动 + if WEB_HOST == "0.0.0.0": + _log.warning(f"绑定 {WEB_HOST} 失败({e}),降级到 127.0.0.1(仅本机可访问)") + _log.warning("如需局域网访问,请用管理员身份运行 start_web.bat") + app.run(host="127.0.0.1", port=WEB_PORT, debug=False, threaded=True) + else: + raise + finally: + mgr.shutdown()