diff --git a/README.md b/README.md index fee48bd..984aab6 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ | **动作配置 / 录制** | 「任务 → 动作配置」:① 配**步骤默认值**(新建步骤预填:滑动时长/幅度/抖动/拟人、点击超时、等待区间…)② **录制动作**——在设备画面上点/划/按键,自动翻译成步骤(点元素记选择器、划一下记方向/幅度/时长),可存成可复用动作,也可只取一个手势回填滑动步骤 | | **公共巡检** | 任务级的守护条件(**独立于步骤画布**):每 N 秒检查屏幕亮/熄、元素在不在、前台是不是某 App,命中就点亮/息屏/停本设备/推通知——通知标题正文自己写(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.5) | | **去重账本** | 解决"**同一个号被做两次、有的号还没做**":任务里用「条件判断→去重」+「记为已做」,把"这个身份做过了"记进一张**所有设备共享**的账本——多台手机、反复重跑都不会重复做同一个号。「任务 → 去重记录」页能看"谁做过了、还差谁",也能删记录让它重跑(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.6) | +| **账号台账** | 「账号」页维护"**哪台设备上登着哪些号**"(设备号/手机号/账号名/抖音号/注册时间/卡在机内/可发视频/简介/备注):支持**从 Excel 粘贴批量导入**(表头识别、逐行结果、先预览后写入)。三个用处:① 任务的「条件判断」**直接从这里取号**,不用把几十个号手写进比对值;② 手机端 Agent 的**身份大字页**显示本机账号;③ 设备池里一眼看到每台登记了几个号(见 [DATA_MODEL.md](doc/DATA_MODEL.md) §2.10、[TASK_DEV.md](doc/TASK_DEV.md) §4.2) | | **元素抓取** | 拉取设备 UI 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 | | **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`) | | **应用管理** | APK 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 | @@ -139,6 +140,7 @@ auto_control/ │ ├── adb_helper.py # adb 命令封装(全局锁,红线:绝不 kill-server) │ ├── device_discovery.py # 网段扫描发现设备 → 待连接池 │ ├── device_battery.py # 设备电量采集(dumpsys 只读)+ 低电量告警 +│ ├── ledger.py # 账号台账(CRUD / 表格粘贴解析 / 给任务取号 / 设备端账号块) │ ├── u2_helper.py # uiautomator2 通用辅助(等首页/安全点击等) │ ├── uiauto_helper.py # uiautodev 客户端(元素树 + XPath 建议) │ ├── ocr.py # 屏幕 OCR(RapidOCR,条件判断用) diff --git a/core/clipboard_helper.py b/core/clipboard_helper.py index 4846382..355325d 100644 --- a/core/clipboard_helper.py +++ b/core/clipboard_helper.py @@ -1,10 +1,13 @@ -"""剪贴板注入公共模块(ClipInject APK 通道)。 +"""剪贴板注入公共模块(设备端 Agent 通道)。 背景:Android 10+ 禁止后台(atx-agent/shell)写剪贴板——u2.set_clipboard -调用"成功"但内容被系统静默丢弃(Android 12/13 实测)。ClipInject -(com.example.clipinject,用户自研 APK)通过 am start 启动透明 Activity, -窗口聚焦后 setPrimaryClip;shell 身份启动 Activity 不受后台启动限制 -(广播方式在 MIUI 会被 Background activity start 拦截,必须用 am start)。 +调用"成功"但内容被系统静默丢弃(Android 12/13 实测)。设备端 APK 通过 am start +启动**透明 Activity**,窗口聚焦后 setPrimaryClip;shell 身份启动 Activity 不受 +后台启动限制(广播方式在 MIUI 会被 Background activity start 拦截,必须用 am start)。 + +**注入目标按顺序尝试**:先设备端聚合 Agent(`com.example.deviceagent`,现在的标准通道), +再兜底老设备上可能残留的独立 ClipInject(`com.example.clipinject`)。 +两者只有一个 `--es text_b64` 的契约差异为零,所以是"换个包名再试一次"。 工具页(web/tools_api.py)与任务执行器(tasks/generic/task.py)共用本模块。 """ @@ -19,17 +22,43 @@ from core.logger import get_logger _log = get_logger("core.clip") -_CLIP_PACKAGE = "com.example.clipinject" -_CLIP_ACTIVITY = "com.example.clipinject/.ClipActivity" +# (包名, am start 目标, 给用户看的名字);顺序 = 尝试顺序 +_CLIP_TARGETS = ( + ("com.example.deviceagent", "com.example.deviceagent/.ClipActivity", "设备端 Agent"), + ("com.example.clipinject", "com.example.clipinject/.ClipActivity", "旧版 ClipInject"), +) + + +def _am_start(serial, activity, b64): + """拉起透明 Activity 写剪贴板。返回 (输出文本, 是否执行成功)。""" + try: + r = subprocess.run( + [ADB_PATH, "-s", serial, "shell", "am", "start", + "-n", activity, "--es", "text_b64", b64], + capture_output=True, timeout=30) + except subprocess.TimeoutExpired: + return "am start 超时(adb 无响应)", False + except Exception as e: + return f"{type(e).__name__}: {str(e)[:120]}", False + out = ((r.stdout or b"") + (r.stderr or b"")).decode(errors="replace") + # "Starting: Intent {…}" 后面跟 "Error type 3 / Error: Activity class … does not exist." + # 时也是失败——所以不能只看有没有 Starting + if "does not exist" in out or "unable to resolve Intent" in out or "Error type 3" in out: + return out, False + if "Error" in out or ("Starting" not in out and r.returncode != 0): + return out, False + return out, True def inject_clipboard(serial, text, d=None): """向设备剪贴板注入文字,返回 (ok, msg)。 + - 依次尝试 `_CLIP_TARGETS`(设备端 Agent → 旧版 ClipInject):设备上装哪个用哪个, + 两个都没有时明确报"需要装设备端 Agent"(不再只说 ClipInject,用户会以为要装老 APK) - d:可选,任务执行器已持有 u2 Device 时传入复用(跳过重复连接), 读回验证直接用它;不传则临时 u2.connect(工具页场景,带超时保护) - IP:port 设备先 adb connect(已连接自动跳过;绝不 disconnect,红线) - - 未装 ClipInject / 读回不一致都会明确报错(不再静默失败) + - 通道缺失 / 读回不一致都会明确报错(不再静默失败) """ import uiautomator2 as u2 if ":" in serial: @@ -39,31 +68,45 @@ def inject_clipboard(serial, text, d=None): return False, f"adb 连接失败: {e}" b64 = base64.b64encode(text.encode("utf-8")).decode() # 1. am start 透明 Activity 写剪贴板(shell 身份,不受后台启动限制) - try: - r = subprocess.run( - [ADB_PATH, "-s", serial, "shell", "am", "start", - "-n", _CLIP_ACTIVITY, "--es", "text_b64", b64], - capture_output=True, timeout=30) - out = ((r.stdout or b"") + (r.stderr or b"")).decode(errors="replace") - if "does not exist" in out or "unable to resolve Intent" in out: - return False, f"设备未安装 ClipInject({_CLIP_PACKAGE}),请先安装" - if "Error" in out or ("Starting" not in out and r.returncode != 0): - return False, f"am start 失败: {out.strip()[:120]}" - except subprocess.TimeoutExpired: - return False, "am start 超时(adb 无响应)" - except Exception as e: - return False, f"{type(e).__name__}: {str(e)[:120]}" - # 2. 读回验证(等透明 Activity 完成写入;u2 读剪贴板在 Android 12 实测可用) - time.sleep(0.5) + tried = [] + for pkg, activity, label in _CLIP_TARGETS: + out, started = _am_start(serial, activity, b64) + if started: + if tried: + # 走到兜底通道:记一条,好提醒"这台设备该装 Agent 了" + _log.info(f"[{serial}] 剪贴板注入:{'、'.join(tried)} 都没有," + f"改用{label}({pkg})成功 —— 建议给这台设备装设备端 Agent") + break + if "does not exist" in out or "unable to resolve Intent" in out or "Error type 3" in out: + tried.append(label) + continue + # 装了但启动失败:直接报错,不再往下试(下一个包大概率也没装) + return False, f"am start 失败({label}): {out.strip()[:120]}" + else: + return False, ("设备未安装剪贴板注入通道:需要设备端 Agent" + "(com.example.deviceagent)——从「应用管理 → 设备端应用商店」安装;" + "旧版独立 ClipInject(com.example.clipinject)也可以,但已不再随平台分发") + # 2. 读回验证:**必须轮询**。透明 Activity 要等窗口拿到焦点才写(还有 800ms 兜底), + # 只睡一次 0.5s 会读到**上一次的剪贴板内容** → 误报"写入可能被拒"(实测踩过: + # 内容明明写进去了,接口却回 1 台失败)。轮询到 ~3 秒:一致即成功; + # 一直不一致才算真的被拒。 try: if d is None: with ThreadPoolExecutor(max_workers=1) as pool: d = pool.submit(u2.connect, serial).result(timeout=30) - with ThreadPoolExecutor(max_workers=1) as pool: - got = pool.submit(lambda: d.clipboard).result(timeout=15) + except Exception as e: + return True, f"已注入(读回验证不可用: {type(e).__name__}: {str(e)[:80]})" + deadline = time.time() + 3.0 + got = None + while True: + time.sleep(0.4) + try: + with ThreadPoolExecutor(max_workers=1) as pool: + got = pool.submit(lambda: d.clipboard).result(timeout=15) + except Exception as e: + # 读回不可用(旧版 u2/连接失败)时按写入成功处理,避免误报,但带上具体原因 + return True, f"已注入(读回验证不可用: {type(e).__name__}: {str(e)[:80]})" if got == text: return True, "已注入" - return False, f"剪贴板读回不一致(写入可能被拒): {str(got)[:60]!r}" - except Exception as e: - # 读回不可用(旧版 u2/连接失败)时按写入成功处理,避免误报,但带上具体原因 - return True, f"已注入(读回验证不可用: {type(e).__name__}: {str(e)[:80]})" + if time.time() >= deadline: + return False, f"剪贴板读回不一致(写入可能被拒): {str(got)[:60]!r}" diff --git a/core/ledger.py b/core/ledger.py new file mode 100644 index 0000000..fc8c8cb --- /dev/null +++ b/core/ledger.py @@ -0,0 +1,526 @@ +"""账号台账:一台设备上登录着哪些账号。 + +**它解决什么**(用户场景):一台手机登好几个抖音号、十几台手机四五十个号, +账号信息散在一张电子表格里 —— 换 IP、加号、想查"这台登的是哪几个号"都得翻表。 +台账落进平台后有三个月牙: + + 1. web「账号」页 —— 列表 / 增删改 / **从表格粘贴导入**(`web/devices_api.py` 的 `/api/ledger*`) + 2. 任务的「条件判断」取号 —— `if_el` 的 `cmp_source=device|all|group` + (比对运算符用「包含」时,纯号 `35377983067` 能匹配元素原文 `抖音号:35377983067`) + 3. 手机端 Agent 的「身份大字页」 —— 平台推 `accounts_b64`(`doc/DEVICE_AGENT.md` §5.2) + +⚠ **台账里的抖音号是纯号,绝不能拿去当去重身份**: +`done_mark.identity` 存的是**元素原文**、逐字算 key,格式不一致(`35377983067` +vs `抖音号:35377983067`)会让去重**静默失效**。取号只用于条件判断的比对值。 + +DB 访问自建 app context(照 `core/dedup.py`),调用方(任务线程 / Web 线程)不用关心。 +""" +import re +import time +import uuid + +from core.logger import get_logger +from core.models import db, Device, DeviceAccount, DeviceGroup + +_log = get_logger("core.ledger") + +# 可编辑字段(接口 / 导入 / 前端共用一份清单,避免某处漏字段) +FIELDS = ("device_name", "phone", "nickname", "douyin_id", "registered_at", + "sim_in_device", "can_post_video", "bio", "note") +# 这两列是布尔("空=否") +BOOL_FIELDS = ("sim_in_device", "can_post_video") + +# 粘贴导入:表头列名 → 字段(允许乱序/缺列/多列;认不出的列忽略) +HEADER_ALIASES = { + "device_name": ("设备号", "设备", "设备名", "机器", "机型"), + "phone": ("手机号", "手机", "号码", "手机号码"), + "nickname": ("账号名称", "账号名", "昵称", "账号", "名称"), + "douyin_id": ("抖音号", "抖音", "抖音id", "douyin"), + "registered_at": ("注册时间", "注册日期", "注册"), + "sim_in_device": ("卡在机内", "卡在机", "卡在", "机内"), + "can_post_video": ("可发视频", "可发", "发视频"), + "bio": ("简介", "签名", "个人简介"), + "note": ("备注", "说明"), +} +# 没有表头时按这个顺序解析(= 用户表格的列顺序,界面上要写明) +DEFAULT_ORDER = FIELDS + +_TRUE_WORDS = ("是", "有", "对", "在", "✔", "√", "✓", "1", "true", "y", "yes", "t") +_FALSE_WORDS = ("否", "无", "没有", "不在", "不", "0", "false", "n", "no", "f", "") +# 抖音号形态:纯数字。不拦(少数号可能带字母),只在导入结果里留痕 —— +# 候选值一旦是错的,任务会"正常跑完但永远不命中",最难查 +_DOUYIN_RE = re.compile(r"^\d{6,20}$") + +_app = None + + +def init_app(app): + """web_server 启动时调用:绑 app(后台线程/任务线程访问 db 要自推 context)。""" + global _app + _app = app + + +def _ctx(): + if _app is None: + raise RuntimeError("ledger 未关联 Flask app(web_server 启动时调用 init_app)") + return _app.app_context() + + +def _now(): + return time.strftime("%Y-%m-%d %H:%M:%S") + + +def _norm_bool(v): + """是/否 → (True/False, 是否认得)。认不出的按"否"处理,但**留痕**。""" + if isinstance(v, bool): + return v, True + s = str(v or "").strip().lower() + if s in _TRUE_WORDS: + return True, True + if s in _FALSE_WORDS: + return False, True + return False, False + + +def _clean(fields): + """把接口来的字段清洗成可落库的值。只处理传进来的键。返回 (values, error)。""" + out = {} + for k in FIELDS: + if k not in (fields or {}): + continue + v = fields.get(k) + out[k] = _norm_bool(v)[0] if k in BOOL_FIELDS else str(v or "").strip() + if "device_name" in out and not out["device_name"]: + return {}, "设备号不能为空" + if "douyin_id" in out and not out["douyin_id"]: + return {}, "抖音号不能为空" + return out, "" + + +# ================== 查 ================== +def get_by_id(acc_id): + """取一条(dict)或 None。 + + **跨边界一律返回 dict**:commit 之后 ORM 行的属性会过期,出了 app context + 再读属性会抛 DetachedInstanceError(接口层 `row.to_dict()` 必炸)。 + """ + with _ctx(): + row = DeviceAccount.query.get(acc_id) + return row.to_dict() if row else None + + +def find_by_douyin(douyin_id, exclude_id=""): + """按抖音号找已存在的那条(唯一性判据;空号不参与)。返回 (id, 设备号) 或 None。 + + 只返回两个纯值,同样是为了不让 ORM 行溜出 context。 + """ + d = str(douyin_id or "").strip() + if not d: + return None + with _ctx(): + q = DeviceAccount.query.filter(DeviceAccount.douyin_id == d) + if exclude_id: + q = q.filter(DeviceAccount.id != exclude_id) + row = q.first() + return (row.id, row.device_name or "") if row else None + + +def list_accounts(device_name="", q="", limit=500): + """列表 + 统计(「账号」页用)。返回 (rows, stats)。""" + with _ctx(): + query = DeviceAccount.query + if device_name: + query = query.filter(DeviceAccount.device_name == device_name) + if q: + like = f"%{q.strip()}%" + query = query.filter(db.or_(DeviceAccount.douyin_id.like(like), + DeviceAccount.phone.like(like), + DeviceAccount.nickname.like(like))) + rows = (query.order_by(DeviceAccount.device_name, DeviceAccount.id) + .limit(max(1, min(int(limit or 500), 2000))).all()) + all_rows = DeviceAccount.query.all() + stats = { + "total": len(all_rows), + "devices": len({r.device_name for r in all_rows if r.device_name}), + "can_post_video": len([r for r in all_rows if r.can_post_video]), + "shown": len(rows), + } + return [r.to_dict() for r in rows], stats + + +def device_names(): + """台账里出现过的设备号(筛选下拉用)。""" + try: + with _ctx(): + return sorted({r.device_name for r in DeviceAccount.query.all() if r.device_name}) + except Exception: + return [] + + +def for_device(serial="", device_name=""): + """这台设备的账号。serial 优先,再按设备名(换 IP / 改名都找得到)。 + + `serial` 是**录入时的快照**:设备换 IP 后台账里那条还是旧地址, + 所以两边都查一遍并按抖音号去重。 + """ + with _ctx(): + rows, seen = [], set() + if serial: + for r in DeviceAccount.query.filter(DeviceAccount.serial == serial).all(): + rows.append(r) + seen.add(r.douyin_id) + name = device_name + if not name and serial: + dev = Device.query.get(serial) + name = (dev.name if dev else "") or "" + if name: + for r in DeviceAccount.query.filter(DeviceAccount.device_name == name).all(): + if r.douyin_id not in seen: + rows.append(r) + seen.add(r.douyin_id) + return [r.to_dict() for r in rows] + + +def resolve_device(serial="", device_name=""): + """serial/设备名 → (设备号, 账号行, 说明)。 + + "本机是哪台设备"这条链只在这一个地方定义,任务侧和设备端都走它 —— + 两套规则必然分叉(那会变成"任务取到 3 个号、页面显示 0 个号"这种鬼故事)。 + """ + name = (device_name or "").strip() + if not name and serial: + with _ctx(): + dev = Device.query.get(serial) + name = (dev.name if dev else "") or "" + rows = for_device(serial=serial, device_name=name) + # 兜底:台账里有人把"设备号"直接填成地址(手填习惯),按 serial 再认一次 + if not rows and serial: + with _ctx(): + hit = DeviceAccount.query.filter(DeviceAccount.device_name == serial).all() + if hit: + name = serial + rows = [r.to_dict() for r in hit] + if not rows: + note = (f"设备名『{name}』在台账里没有账号" if name + else f"设备 {serial or '(无地址)'} 认不出设备名(设备池里没登记或还没刷新)") + if serial and name: + note += f",地址 {serial} 也没对上台账" + return name, [], note + return name, rows, f"设备『{name}』登记 {len(rows)} 个号" + + +def douyin_ids(scope, serial="", device_name="", group=""): + """按范围取抖音号(任务「条件判断」取号用)。返回 (去重后的纯号列表, 说明)。 + + 说明是给日志/步骤明细看的**人话解释** —— 取不到号时它是唯一线索 + (取不到候选值的后果是"这一步永远不命中",但任务本身会正常跑完)。 + scope: `device`=本机台账 / `all`=全部 / `group`=某设备分组 / 其他=不取 + """ + scope = (scope or "").strip() + if scope in ("", "manual", "none"): + return [], "" + try: + with _ctx(): + if scope == "device": + _, rows, note = resolve_device(serial=serial, device_name=device_name) + return _uniq([r.get("douyin_id") for r in rows]), note + if scope == "all": + ids = _uniq([r.douyin_id for r in DeviceAccount.query.all()]) + return ids, f"全部台账共 {len(ids)} 个号" + if scope == "group": + names = _device_names_of_group(group) + if not names: + return [], f"设备分组『{group}』没有设备(或分组不存在)" + rows = DeviceAccount.query.filter( + DeviceAccount.device_name.in_(list(names))).all() + ids = _uniq([r.douyin_id for r in rows]) + return ids, f"设备分组『{group}』({len(names)} 台设备)共 {len(ids)} 个号" + return [], f"未知的取号范围 {scope!r}" + except Exception as e: + # 取号失败绝不拦住任务:返回空,由调用方回落到手填值 + _log.warning(f"台账取号失败(回落手填值): {e}") + return [], f"台账取号失败: {e}" + + +def _uniq(ids): + out, seen = [], set() + for i in ids or []: + s = str(i or "").strip() + if s and s not in seen: + seen.add(s) + out.append(s) + return out + + +def _device_names_of_group(group_name): + """设备分组 → 设备名集合(分组里存的是 serial,这里换成台账里的设备号)。""" + g = DeviceGroup.query.filter(DeviceGroup.name == (group_name or "").strip()).first() + if not g: + return set() + serials = g.get_serials() or [] + if not serials: + return set() + devs = Device.query.filter(Device.serial.in_(list(serials))).all() + return {d.name for d in devs if d.name} + + +def counts_by_device(): + """{设备名: 号数}(设备池显示"这台几个号"用,一次拿全避免 N 次请求)。""" + try: + with _ctx(): + out = {} + for r in DeviceAccount.query.all(): + if r.device_name: + out[r.device_name] = out.get(r.device_name, 0) + 1 + return out + except Exception as e: + _log.warning(f"台账统计失败: {e}") + return {} + + +def by_device(): + """{设备名: {count, accounts[]}}(设备维度查看弹窗用)。""" + groups = {} + try: + with _ctx(): + for r in DeviceAccount.query.order_by(DeviceAccount.douyin_id).all(): + g = groups.setdefault(r.device_name or "", {"count": 0, "accounts": []}) + g["count"] += 1 + g["accounts"].append(r.to_dict()) + except Exception as e: + _log.warning(f"台账按设备分组失败: {e}") + return groups + + +def agent_accounts(serial="", device_name="", limit=50, include_phone=False): + """设备端 Agent 的账号块(身份大字页用)。 + + · **默认不带手机号**:大字页是放在机器旁的公开屏幕,手机号不该默认上屏 + · 超量**按整条丢**(`truncated=True`),绝不按字节切 —— 切了就是坏 JSON, + 设备端只能整块丢掉 + · 任何异常都返回空块(不是抛):台账坏了不能把"显示身份"这个动作弄坏 + """ + try: + name, rows, note = resolve_device(serial=serial, device_name=device_name) + out = [] + for r in rows[:max(1, int(limit or 50))]: + item = {"name": r.get("nickname") or "", "douyin_id": r.get("douyin_id") or "", + "device_name": r.get("device_name") or "", "device_no": name, + "sim_in_device": bool(r.get("sim_in_device")), + "can_post_video": bool(r.get("can_post_video"))} + if include_phone: + item["phone"] = r.get("phone") or "" + out.append(item) + return {"v": 1, "total": len(rows), "shown": len(out), + "truncated": len(out) < len(rows), "accounts": out, "note": note} + except Exception as e: + _log.warning(f"设备端账号块生成失败: {e}") + return {"v": 1, "total": 0, "shown": 0, "truncated": False, "accounts": [], + "note": f"读取失败: {e}"} + + +# ================== 增 / 改 / 删 ================== +def add(fields): + """新增一条。返回 (dict, error)。""" + values, err = _clean(fields) + if err: + return None, err + if not values.get("device_name"): + return None, "设备号不能为空" + if not values.get("douyin_id"): + return None, "抖音号不能为空" + # try 必须在 `with _ctx()` 里面:否则 rollback 自己会在 context 外抛 + # RuntimeError,把真正的错误盖掉(这条踩过一次,别挪出去) + with _ctx(): + try: + if find_by_douyin(values["douyin_id"]): + return None, f"抖音号 {values['douyin_id']} 已在台账里(一个号只能有一条)" + row = DeviceAccount(id=uuid.uuid4().hex[:8], created_at=_now(), updated_at=_now()) + for k in FIELDS: + # 没传的布尔列必须是 False,不能是 ""(Boolean 列收到空串会直接报 + # TypeError: Not a boolean value,整条插不进去) + setattr(row, k, values.get(k, False if k in BOOL_FIELDS else "")) + _fill_serial(row) + db.session.add(row) + db.session.commit() + _log.info(f"账号台账新增:{row.device_name} / {row.nickname} / {row.douyin_id}") + return row.to_dict(), "" # 出 context 前转成纯值 + except Exception as e: + db.session.rollback() + return None, f"保存失败: {e}" + + +def update(acc_id, fields): + """改一条(只改传进来的键)。返回 (dict, error)。""" + with _ctx(): + try: + row = DeviceAccount.query.get(acc_id) + if not row: + return None, "记录不存在" + values, err = _clean(fields) + if err: + return None, err + new_id = values.get("douyin_id", row.douyin_id) + dup = find_by_douyin(new_id, exclude_id=acc_id) + if dup: + return None, f"抖音号 {new_id} 已被 {dup[1]} 占用(一个号只能有一条)" + for k, v in values.items(): + setattr(row, k, v) + row.updated_at = _now() + _fill_serial(row) + db.session.commit() + return row.to_dict(), "" + except Exception as e: + db.session.rollback() + return None, f"保存失败: {e}" + + +def delete(acc_id): + """删一条(删掉之后任务就取不到这个号了)。""" + with _ctx(): + try: + row = DeviceAccount.query.get(acc_id) + if not row: + return False + db.session.delete(row) + db.session.commit() + _log.info(f"账号台账删除:{row.device_name} / {row.nickname} / {row.douyin_id}") + return True + except Exception as e: + db.session.rollback() + _log.warning(f"账号台账删除失败: {e}") + return False + + +def _fill_serial(row): + """按设备名补 serial 快照(台账只填设备号时,自动关联设备池那条)。""" + if row.serial: + return + if not row.device_name: + return + dev = Device.query.filter(Device.name == row.device_name).first() + if dev: + row.serial = dev.serial + + +# ================== 粘贴导入 ================== +def parse_paste(text, delimiter="\t"): + """把粘贴的表格文本解析成台账行。返回 (rows, errors)。 + + rows 里每行是 `{"_line": 3, "device_name": "...", ..., "_warns": [...]}` + (`_line` = 粘贴文本里的行号,界面把错误指回原行;`_warns` 是"能导但要注意"的提示)。 + errors 是 `[{"line": 3, "reason": "..."}]`(这些行会被跳过)。 + + 分隔符默认 Tab(Excel 直接粘贴就是 Tab)。**不做"多个空格当分隔"**—— + 账号名里本来就有双空格(实测 `有牛奶面包 你吃吗?`),按空格切会切坏。 + 带表头时按列名映射(顺序随意、缺列留空);不带表头则按 DEFAULT_ORDER 的固定顺序。 + """ + if delimiter not in ("\t", "|", ","): + delimiter = "\t" + text = (text or "").replace("\r\n", "\n").replace("\r", "\n") + lines = [(i + 1, ln) for i, ln in enumerate(text.split("\n")) if ln.strip()] + if not lines: + return [], [{"line": 0, "reason": "没有可解析的内容"}] + + first_cells = [c.strip() for c in lines[0][1].split(delimiter)] + mapped = _map_header(first_cells) + if mapped: + body = lines[1:] + else: + mapped = {name: i for i, name in enumerate(DEFAULT_ORDER)} + body = lines + if not body: + return [], [{"line": lines[0][0], "reason": "只有表头,没有数据行"}] + + rows, errors = [], [] + for lineno, line in body: + cells = line.split(delimiter) + row = {"_line": lineno, "_warns": []} + for f in FIELDS: # 先把所有字段填上,缺列留空 + row[f] = "" + for field, idx in mapped.items(): + if field in FIELDS: + row[field] = cells[idx].strip() if idx < len(cells) else "" + if not row["device_name"] and not row["douyin_id"]: + errors.append({"line": lineno, "reason": "设备号与抖音号都是空的,已跳过"}) + continue + if not row["device_name"]: + errors.append({"line": lineno, "reason": "缺设备号,已跳过"}) + continue + if not row["douyin_id"]: + errors.append({"line": lineno, "reason": "缺抖音号,已跳过"}) + continue + for f in BOOL_FIELDS: + raw = row[f] + val, known = _norm_bool(raw) + row[f] = val + if not known: + row["_warns"].append(f"『{raw}』认不出,按「否」处理") + if not _DOUYIN_RE.match(row["douyin_id"]): + row["_warns"].append(f"抖音号『{row['douyin_id']}』不是纯数字 —— " + f"任务比对时可能匹配不上") + rows.append(row) + return rows, errors + + +def _map_header(cells): + """列名 → 列下标(认不出"设备号+抖音号"时返回 {},按固定顺序解析)。""" + mapping = {} + for idx, cell in enumerate(cells): + c = re.sub(r"\s+", "", cell or "") + if not c: + continue + for field, aliases in HEADER_ALIASES.items(): + if field in mapping: + continue + if any(c == a or c.startswith(a) for a in aliases): + mapping[field] = idx + break + if "device_name" in mapping and "douyin_id" in mapping: + return mapping + return {} + + +def import_rows(rows, mode="skip", dry_run=False): + """把解析好的行落库(或只预览)。返回逐行结果 + 汇总。 + + mode=`skip`(默认):抖音号已在台账里 → 跳过(不动现有那条) + mode=`overwrite`:抖音号已在台账里 → 用粘贴的这行**整行覆盖**它 + dry_run=True:只判重、不写库(界面上"先预览后导入") + """ + mode = mode if mode in ("skip", "overwrite") else "skip" + counts = {"total": 0, "added": 0, "updated": 0, "skipped": 0, "failed": 0} + out_rows = [] + for row in rows or []: + line = row.get("_line", 0) + fields = {k: v for k, v in row.items() if k not in ("_line", "_warns")} + counts["total"] += 1 + existing = find_by_douyin(fields.get("douyin_id")) + item = {"line": line, "device_name": fields.get("device_name", ""), + "douyin_id": fields.get("douyin_id", ""), + "nickname": fields.get("nickname", ""), + "warns": list(row.get("_warns") or []), "error": ""} + if existing: + if mode == "overwrite": + item["action"] = "update" + if not dry_run: + _, err = update(existing[0], fields) + if err: + item["action"], item["error"] = "fail", err + counts["updated" if item["action"] == "update" else "failed"] += 1 + else: + item["action"] = "skip" + item["error"] = f"已存在({existing[1]}),跳过" + counts["skipped"] += 1 + else: + item["action"] = "add" + if not dry_run: + _, err = add(fields) + if err: + item["action"], item["error"] = "fail", err + counts["added" if item["action"] == "add" else "failed"] += 1 + out_rows.append(item) + if not dry_run: + _log.info(f"账号台账导入:新增 {counts['added']}、覆盖 {counts['updated']}、" + f"跳过 {counts['skipped']}、失败 {counts['failed']}") + return {"counts": counts, "rows": out_rows, "mode": mode, "dry_run": bool(dry_run)} diff --git a/core/models.py b/core/models.py index 63ce3e1..45d761d 100644 --- a/core/models.py +++ b/core/models.py @@ -436,6 +436,54 @@ class DoneMark(db.Model): ) +class DeviceAccount(db.Model): + """账号台账:一台设备上登录着哪些账号(「账号」页)。 + + 要解决的问题(用户场景):一台手机登好几个抖音号、十几台手机四五十个号, + 账号信息散在一张电子表格里 —— 换 IP、加号、想查"这台登的是哪几个号"都要翻表。 + 这里把台账落进平台:web 能查能改(支持从表格粘贴导入), + 手机端 Agent 的「身份大字页」顺带显示本机账号,任务的「条件判断」 + 也可以直接从台账取号(不用再把号一个个手写进 cmp_value)。 + + ⚠ **`douyin_id` 是纯号(`35377983067`),而元素原文是 `抖音号:35377983067`。** + - 当条件判断的**比对值**可以 ✓(运算符用「包含」,纯号是子串) + - **绝不能当去重身份** ✗ —— `done_mark.identity` 存的是元素原文、逐字算 key, + 格式不一致会让去重**静默失效**(见 `core/dedup.py`)。 + + `device_name` 是设备号(平台设备名,如 A01);`serial` 是**录入时的地址快照**, + 设备换 IP 或改名后,台账靠任一侧都能找回来(见 `core/ledger.for_device`)。 + 同一抖音号不允许两条 —— 唯一性由服务层保证(号可能为空,DB 层要做 + "部分唯一索引"三处方言适配,人工维护的几十条不值当)。 + """ + __tablename__ = "device_account" + id = db.Column(db.String(32), primary_key=True) # uuid 前 8 位 + device_name = db.Column(db.String(80), default="", index=True) # 设备号(平台设备名) + serial = db.Column(db.String(120), default="") # 录入时的地址快照 + phone = db.Column(db.String(32), default="", index=True) # 手机号 + nickname = db.Column(db.String(80), default="") # 账号名称 + douyin_id = db.Column(db.String(64), default="", index=True) # 抖音号(纯号,不带前缀) + registered_at = db.Column(db.String(20), default="") # 注册时间(原样存文本) + sim_in_device = db.Column(db.Boolean, default=False) # 卡在机内(空=否) + can_post_video = db.Column(db.Boolean, default=False) # 可发视频(空=否) + bio = db.Column(db.Text, default="") # 简介 + note = db.Column(db.Text, default="") # 备注 + created_at = db.Column(db.String(20), default="") + updated_at = db.Column(db.String(20), default="") + + def to_dict(self): + return {"id": self.id, "device_name": self.device_name or "", + "serial": self.serial or "", "phone": self.phone or "", + "nickname": self.nickname or "", "douyin_id": self.douyin_id or "", + "registered_at": self.registered_at or "", + "sim_in_device": bool(self.sim_in_device), + "can_post_video": bool(self.can_post_video), + "bio": self.bio or "", "note": self.note or "", + "created_at": self.created_at or "", "updated_at": self.updated_at or ""} + + def __repr__(self): + return f"" + + class AgentConversation(db.Model): """AI 控制台会话:整个消息序列以 JSON 存在一行里(单会话几十 KB,够用)。""" __tablename__ = "agent_conversation" @@ -461,6 +509,7 @@ SCHEMA_MIGRATIONS = [ (5, "设备池:device 表新增 fingerprint 列(设备指纹 ro.serialno,换 IP 后认领回原记录)", None), (6, "自动发现:pending_device 表新增 fingerprint 列(扫描时读取,用于提示是已有设备换了 IP)", None), (7, "去重账本:done_mark 表(跨设备幂等的「已做过」标记,唯一索引 scope_key)", None), + (8, "账号台账:device_account 表(设备号/手机号/账号名称/抖音号/注册时间/卡在机内/可发视频/简介/备注)", None), ] # 当前 schema 版本(备份/恢复用它判断新旧,也写进 app_meta.schema_version) diff --git a/core/system_backup.py b/core/system_backup.py index ab99fa2..9d39273 100644 --- a/core/system_backup.py +++ b/core/system_backup.py @@ -58,6 +58,7 @@ TABLE_LABELS = { "agent_action": "动作库", "device_install_log": "设备端安装记录", "task_step_log": "任务步骤明细", "done_mark": "去重记录(已做过)", + "device_account": "账号台账", } _STAGE_TTL = 1800 # 导入暂存有效期(秒) diff --git a/core/task_draft.py b/core/task_draft.py index 7a25e50..37f4d6c 100644 --- a/core/task_draft.py +++ b/core/task_draft.py @@ -225,12 +225,21 @@ def validate_steps(steps, depth=1, path="steps", errors=None, warnings=None, # 执行器对认不出的运算符是"一律不命中",写错了会静默走 else, # 所以在这里拦住,别让 AI 草稿悄悄变成"永远不命中" cmp_op = (params.get("cmp_op") or "").strip() + cmp_src = (params.get("cmp_source") or "").strip() + if cmp_src not in ("", "manual", "device", "all", "group"): + errors.append(f"{here}.params.cmp_source: 不支持的候选值来源 {cmp_src!r}" + f"(可用:空/manual/device/all/group)") + if cmp_src == "group" and not (params.get("cmp_group") or "").strip(): + warnings.append(f"{here}: 候选值来源是「按设备分组」,但没填分组名 —— 取不到号") if cmp_op: from tasks.generic.task import CMP_OPS if cmp_op not in CMP_OPS: errors.append(f"{here}.params.cmp_op: 不支持的比对运算符 {cmp_op!r}" f"(可用:{'、'.join(CMP_OPS)})") - elif not str(params.get("cmp_value") or "").strip(): + elif not str(params.get("cmp_value") or "").strip() \ + and cmp_src not in ("device", "all", "group"): + # 候选值来自台账时允许手填留空(台账取号就够); + # 其余情况仍要求填值,否则永远不命中 errors.append(f"{here}.params.cmp_value: 选了文本比对" f"({cmp_op})就必须填比对的值,否则永远不命中") elif (params.get("selector_type") or "xpath") == "screen": diff --git a/doc/API.md b/doc/API.md index 41773ea..0329f0d 100644 --- a/doc/API.md +++ b/doc/API.md @@ -137,6 +137,12 @@ | GET | `/api/done_marks?job=&limit=` | L | 去重记录列表 + 统计(`{marks, stats{total,devices,identities,today_devices,today_identities}}`) | | POST | `/api/done_marks/delete` | T | 删一条去重记录(`{id}`)→ 该设备/身份下次会重新执行 | | POST | `/api/done_marks/clear` | T | 清空某任务的全部去重记录(`{job}`)→ 整批重跑 | +| GET | `/api/ledger?device=&q=&limit=` | D | 账号台账列表 + 统计(`{accounts, stats{total,devices,can_post_video,shown}, device_names}`) | +| GET | `/api/ledger/by_device` | D | 按设备分组(`{devices:{设备号:{count,accounts[]}}, counts:{设备号:N}}`) | +| POST | `/api/ledger` | D | 新增账号(抖音号重复 → 400) | +| PUT | `/api/ledger/` | D | 改一条账号 | +| DELETE | `/api/ledger/` | D | 删一条账号 | +| POST | `/api/ledger/import` | D | 从表格粘贴文本批量导入(`dry_run=true` 只预览不写库) | ### 2.4 admin(`web/admin_api.py`) @@ -482,6 +488,36 @@ 账本表与语义见 [DATA_MODEL.md](DATA_MODEL.md) §2.9;任务侧怎么用见 [TASK_DEV.md](TASK_DEV.md) §4.6。 +### 账号台账(「账号」页) + +数据在 `device_account` 表([DATA_MODEL.md](DATA_MODEL.md) §2.10),服务层 `core/ledger.py`, +权限 `devices`。 + +| 接口 | 请求 | 响应要点 | +|------|------|---------| +| `GET /api/ledger` | `?device=<设备号>&q=<搜索>&limit=500` | `{ok, accounts:[{id,device_name,serial,phone,nickname,douyin_id,registered_at,sim_in_device,can_post_video,bio,note,created_at,updated_at}], stats:{total,devices,can_post_video,shown}, device_names:["A01",…]}`。`q` 匹配抖音号/手机号/账号名 | +| `GET /api/ledger/by_device` | — | `{ok, devices:{A01:{count,accounts:[…]}}, counts:{A01:4}}`(设备池"账号 N"列 + 点开看明细都用它,省一次请求) | +| `POST /api/ledger` | 9 个字段(`device_name`/`douyin_id` 必填) | `{ok,msg,account}`;缺必填或抖音号重复 → **400** | +| `PUT /api/ledger/` | 局部字段 | `{ok,msg,account}`;不存在 → 404;抖音号撞车 → 400 | +| `DELETE /api/ledger/` | — | `{ok,msg}`;不存在 → 404 | +| `POST /api/ledger/import` | `{"text":"…","delimiter":"\\t","mode":"skip\|overwrite","dry_run":true}` | `{ok, counts:{total,added,updated,skipped,failed}, rows:[{line,action,device_name,douyin_id,nickname,error,warns}], errors:[{line,reason}], mode, dry_run}` | + +**导入语义**(解析规则见 `core/ledger.parse_paste`): + +- 分隔符默认 **Tab**(Excel 直接粘贴就是 Tab);也支持 `|` 与 `,`。**不做"多个空格当分隔"**—— + 账号名里本来就有双空格(实测 `有牛奶面包 你吃吗?`)。 +- **带表头**时按列名映射(顺序随意、缺列留空、认不出的列忽略);**不带表头**则按 + 设备号/手机号/账号名称/抖音号/注册时间/卡在机内/可发视频/简介/备注 的固定顺序。 +- 「卡在机内 / 可发视频」认 `是/有/1/true` → True,`否/空` → False;认不出的值按"否"处理 + 并在该行 `warns` 里留痕。 +- 抖音号非纯数字也会 `warns` 留痕(不拦)——它会让任务比对匹配不上。 +- `mode=skip`:库里有同号 → 跳过;`mode=overwrite`:**整行替换**(空单元格会覆盖掉原值)。 +- `dry_run=true` 走同一条解析+判重路径但**一个字节都不写**(界面默认先预览再导入)。 +- 缺设备号或抖音号的行会被跳过,并在 `errors` 里带行号说明。 + +> ⚠ 台账里的抖音号是**纯号**,只当"比对用的候选值";**不要**拿它填「去重」的身份元素 +> (身份是元素原文逐字算 key,格式不同会让去重**静默失效**)。 + --- ## 6. 设备池与自动发现 @@ -719,7 +755,7 @@ | 接口 | 请求 | 说明 | |------|------|------| -| `POST /api/tools/clipboard/set` | `{"serials":[…],"text":"…"}` | ClipInject 通道写入并读回校验;serials 空或非列表 → 400 | +| `POST /api/tools/clipboard/set` | `{"serials":[…],"text":"…"}` | 设备端 Agent 通道写入并读回校验(旧版独立 ClipInject 自动兜底);serials 空或非列表 → 400 | | `POST /api/tools/appver` | `{"package":"com.xxx"}` | 并发查所有在线设备(≤10 并发);包名须匹配 `^[A-Za-z0-9_.]+$` | ### Tailscale(全部 Admin) diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 7ebe2f7..02cad63 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -270,7 +270,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set ### 6.1 单页应用 -- 主页面 `templates/admin/monitor.html`:一个内联 `