feat(发布计划): 改按名字发(文件名 名字_日期_编号)+ 账号管理整页下线 + 自动切号一轮发完
发布计划不再绑设备、不再依赖账号台账:上传文件名 `名字_日期_编号`(标题行 `标题 || 正文_名字_日期_编号`),运行时读本机当前抖音昵称取计划;靠循环块 「一轮把本机待发的号跑完」自动切号,一直发到发完。 · 解析/配对换键:右锚定认名字(不许含下划线、不许纯数字 —— 不合法直接拒收并说清原因), 删掉三处"手机号必须命中台账"的校验,加索引 ix_video_plan_name_date(启动时补建) · 新步骤三个:读本机账号名字 / 切换账号(**按名字点控件**、切完回读校验、遇登录页停本设备)/ 循环本机待发的号(试过不回头、跑一轮没进展就换号、停止·超时·上限三个干净出口) · 发布骨架改 18 步:读名字 → 校验账号(cmp_source=account_target)→ 推送(**推送前重读昵称核对**) → 抖音流程 → 标记结果;push_release 的 account 默认 name · 账号管理整页下线:删 core/ledger.py(serial_of 迁到 core/device_pool.py)、6 个 /api/ledger*、 「账号」页与 ledger.js、设备池「账号」列、设备端账号块;发布计划升为一级页签; device_account 表与 42 行历史数据保留、仍在备份清单内(**停用不等于删数据**) · 人工入口「按名字找设备」:新增 core/account_probe.py(只读探测、跳过在跑任务的设备) + POST /api/video_plan/push_by_account,没设备登着的号逐条报出来; 推送时把"实际是谁发的"写进 posted_serial / posted_device_name · 通知:新增 task.account.switch_failed / task.account.need_login · schema v12(posted_*)/ v13(device.cur_account_name / cur_name_at) · 文档同步:TASK_DEV / API / DATA_MODEL / ARCHITECTURE / NOTIFY / DEVICE_AGENT / DEPLOY / DEVELOPMENT / README / .env.example(两个抖音控件选择器抓一次填 .env) · 新增 scripts/tests/:6 套用例 + run_all.py(临时库 + 假设备,不碰真实数据、不连真机)
This commit is contained in:
@@ -354,7 +354,8 @@ def push_one(serial, plan, opts=None, log=None):
|
||||
f"—— 后面用「粘贴」步骤就能填进抖音")
|
||||
except Exception as e:
|
||||
log(f"标题写剪贴板失败(不影响推送): {e}")
|
||||
vp.mark_pushed(plan_id, remote=remote, verify=verify)
|
||||
vp.mark_pushed(plan_id, remote=remote, verify=verify, serial=serial,
|
||||
device_name=opts.get("device_name") or "")
|
||||
return True, (f"已推送到手机:{remote}" if verify == "ok"
|
||||
else f"已推送到手机:{remote}(⚠ {vmsg})")
|
||||
except Exception as e:
|
||||
|
||||
+443
-51
@@ -41,7 +41,7 @@ from core.device_worker import BaseWorker, _update_status
|
||||
from core.u2_helper import (ensure_app_running, wait_for_app_home, random_sleep,
|
||||
screen_is_on, current_package)
|
||||
from core.logger import get_logger
|
||||
from core import notifier, step_log, humanize, patrol, gesture, dedup, ledger
|
||||
from core import notifier, step_log, humanize, patrol, gesture, dedup
|
||||
|
||||
_log = get_logger("task.generic")
|
||||
|
||||
@@ -129,9 +129,22 @@ STEP_TYPES = [
|
||||
"then": [], "else": []}},
|
||||
{"type": "mark_done", "label": "记为已做", "icon": "✅",
|
||||
"params": {"selector_type": "text", "selector_value": ""}},
|
||||
{"type": "read_account_name", "label": "读取本机账号名字", "icon": "🏷",
|
||||
"params": {"selector_type": "xpath", "selector_value": "",
|
||||
"timeout": 8, "on_missing": "fail"}},
|
||||
{"type": "switch_account", "label": "切换账号", "icon": "🔁",
|
||||
"params": {"target": "var:target_name", "target_value": "",
|
||||
"switch_selector_type": "xpath", "switch_selector_value": "",
|
||||
"name_selector_type": "", "name_selector_value": "",
|
||||
"me_selector_type": "descriptionContains", "me_selector_value": "我",
|
||||
"timeout": 8, "verify": True, "max_attempts": 2,
|
||||
"on_login_page": "stop"}},
|
||||
{"type": "foreach_account", "label": "循环本机待发的号", "icon": "🔂",
|
||||
"params": {"date_source": "today", "fixed_date": "", "retry_failed": True,
|
||||
"max_accounts": 0, "children": []}},
|
||||
{"type": "push_release", "label": "推送发布视频", "icon": "📤",
|
||||
"params": {"date_source": "today", "fixed_date": "",
|
||||
"account": "device", "account_phone": "",
|
||||
"account": "name", "account_phone": "",
|
||||
"album_dir": "/sdcard/DCIM/Camera", "to_clipboard": True,
|
||||
"retry_failed": True, "max_same_run": 1}},
|
||||
{"type": "mark_release", "label": "标记发布结果", "icon": "🏁",
|
||||
@@ -254,6 +267,11 @@ class GenericStepsWorker(BaseWorker):
|
||||
# 保证"检查"与"记账"用的是**同一个身份字符串**(否则 key 对不上、去重失效)
|
||||
self._last_ident = ""
|
||||
self._last_release = None # 「推送发布视频」推的那条计划,供「标记发布结果」用
|
||||
# 运行期变量(一台设备一个 worker 实例,天然隔离,不同任务不会串):
|
||||
# account_name 「读取本机账号名字」刚读到的名字
|
||||
# name_selector 读名字用的选择器 (sel_type, sel_val) —— 推送前核对要用同一个控件
|
||||
# target_name 循环块(foreach_account)本轮设的**目标名字**
|
||||
self._vars = {}
|
||||
self._dedup_seen = False # 本轮是否跑过「去重」检查(区分"没配"与"配了但没读到")
|
||||
# 公共巡检(任务级配置,独立于步骤画布;见 core/patrol.py):
|
||||
# _wid 内部标识,用来记"下次到点 / 上次命中"两个节奏
|
||||
@@ -366,8 +384,8 @@ class GenericStepsWorker(BaseWorker):
|
||||
_log.warning(f"[{self.serial}] 未知步骤类型: {stype}")
|
||||
result, detail = "unknown", f"未知步骤类型: {stype}"
|
||||
self._record_step(step, result, detail, int((time.time() - t0) * 1000))
|
||||
# 容器步骤(loop/group/if_el)不计入操作计数,避免监控显示噪音
|
||||
if stype not in ("loop", "group", "if_el"):
|
||||
# 容器步骤(loop/group/if_el/foreach_account)不计入操作计数,避免监控显示噪音
|
||||
if stype not in ("loop", "group", "if_el", "foreach_account"):
|
||||
self._action_counts[label] = self._action_counts.get(label, 0) + 1
|
||||
elapsed = self.elapsed()
|
||||
# done=累计执行的操作次数(无固定总数),前端显示"已执行 N 次操作"
|
||||
@@ -839,21 +857,25 @@ class GenericStepsWorker(BaseWorker):
|
||||
· 取 0 个号时**必须留痕**:候选为空 ⇒ 这一步永远走 else 分支,而任务本身
|
||||
"跑完了、显示成功",不写日志就只能靠人猜。
|
||||
· ⚠ 这里产出的只是**比对用的文本**,绝不参与去重 identity:
|
||||
身份是元素原文逐字算 key,台账里是纯号,格式不同会让去重静默失效
|
||||
(见 core/ledger.py 顶部的警告)。
|
||||
身份是元素原文逐字算 key,候选值格式不同(`小王` vs `昵称:小王`)会让
|
||||
去重静默失效(见 core/dedup.py 的 build_key)。
|
||||
"""
|
||||
source = str(params.get("cmp_source") or "").strip()
|
||||
manual = split_cmp_values(manual_raw)
|
||||
if source == "release":
|
||||
# 「发布计划」:本机**下一条将要推送**的那条计划(推送前校验账号就靠它)。
|
||||
# 候选值给两个:抖音号(纯号)+ 账号名称 —— 元素原文可能是
|
||||
# `抖音号:35377983067`,也可能只显示昵称,两个都可命中(OR 语义)。
|
||||
# 「发布计划」:本机**下一条将要推送**的那条计划的**名字**(推送前校验账号就靠它)。
|
||||
ids, note = self._release_candidates()
|
||||
elif source in ("device", "all", "group"):
|
||||
ids, note = ledger.douyin_ids(
|
||||
source, serial=self.serial,
|
||||
device_name=(self.ctx or {}).get("device_name", ""),
|
||||
group=str(params.get("cmp_group") or "").strip())
|
||||
elif source == "account_target":
|
||||
# 「本次要发的名字」= 循环块设的目标名字,没有就用刚读到的名字(见 _release_scope)。
|
||||
# ⚠ 读不到名字时候选**为空** → 永远不命中 → 走 else 分支(**不发**)。
|
||||
# 这正是想要的:读不到名字 = 不知道现在登的是谁 = 不能发。
|
||||
name = self._scope_name()
|
||||
if not name:
|
||||
_log.warning(f"[{self.serial}] 条件判断:本次要发的名字读不到"
|
||||
f"(前面要有「读取本机账号名字」或循环块设目标)→ 候选为空,"
|
||||
f"这一步永远不命中(走未找到分支)")
|
||||
return manual_raw, "没读到本次要发的名字(读不到 = 不发)"
|
||||
ids, note = [name], f"本次要发的名字『{name}』"
|
||||
else:
|
||||
return manual_raw, "" # 手填(默认):行为与以前完全一致
|
||||
merged = list(ids) + [v for v in manual if v not in ids]
|
||||
@@ -862,48 +884,334 @@ class GenericStepsWorker(BaseWorker):
|
||||
f"[{self.serial}] 条件判断取号为空(来源 {source}):{note};手填候选 "
|
||||
f"{len(manual)} 个 → "
|
||||
+ ("仅用手填值" if manual else "候选为空,这一步将永远不命中(走未找到分支)"))
|
||||
where = "发布计划" if source == "release" else f"台账({source})"
|
||||
src_txt = (f"候选来自{where} {len(ids)} 个号" if ids
|
||||
else f"{where}取号为空:{note}")
|
||||
where = "发布计划" if source == "release" else "本次要发的名字"
|
||||
src_txt = (f"候选来自{where}:{'、'.join(ids)}" if ids
|
||||
else f"{where}取不到:{note}")
|
||||
if manual:
|
||||
src_txt += f",另加手填 {len(manual)} 个"
|
||||
return "\n".join(merged), src_txt
|
||||
|
||||
def _release_targets(self, params):
|
||||
"""推送/标记步骤共用的"取本机账号手机号"逻辑。返回 (phones, who)。"""
|
||||
from core import ledger
|
||||
phone = (params.get("account_phone") or "").strip()
|
||||
if (params.get("account") or "device") == "phone" and phone:
|
||||
return [phone], f"指定手机号 {phone}"
|
||||
_, rows, note = ledger.resolve_device(
|
||||
serial=self.serial, device_name=(self.ctx or {}).get("device_name", ""))
|
||||
return [r.get("phone") for r in rows if r.get("phone")], f"本机账号({note})"
|
||||
def _scope_name(self):
|
||||
"""本次要发的**名字**:循环块设的目标优先,其次刚读到的本机账号名字。取不到返回 ""。"""
|
||||
return ((self._vars.get("target_name") or "").strip()
|
||||
or (self._vars.get("account_name") or "").strip())
|
||||
|
||||
def _release_scope(self, params):
|
||||
"""推送/标记/输入文字共用的"这次发的是哪个号"。返回 `(mode, values, who)`。
|
||||
|
||||
`account` 参数:
|
||||
· `name`(默认)—— **本次要发的名字**(`_scope_name()`)。名字取不到时 values 为空,
|
||||
调用方按"没有号"跳过:**绝不猜一个号去发**(读不到名字 = 不发)。
|
||||
· `phone` —— 手填手机号(老任务兼容;账号台账已停用,新任务别再用这条)。
|
||||
|
||||
**这个口径只在这一处定义**(`push_release` / `mark_release` / `input_text` 都走它):
|
||||
分叉就会出现"推的是 A 号、标记的是 B 号"这种对不上的账。
|
||||
"""
|
||||
mode = (params.get("account") or "name").strip().lower()
|
||||
if mode == "phone":
|
||||
phone = (params.get("account_phone") or "").strip()
|
||||
return ("phone", [phone] if phone else [],
|
||||
f"指定手机号 {phone}" if phone else "没填手机号 → 跳过")
|
||||
name = self._scope_name()
|
||||
if not name:
|
||||
return ("name", [],
|
||||
"没读到本机账号名字(前面要有「读取本机账号名字」,"
|
||||
"或由循环块设目标名字)→ 跳过")
|
||||
return ("name", [name], f"名字『{name}』")
|
||||
|
||||
def _release_fetch(self, mode, values, date="", how="take", limit=1, retry_failed=True):
|
||||
"""按 scope 取计划。`how`:`take`=候选(**仍要 claim 占位**)/ `peek`=看下一条(不占位) /
|
||||
`last`=最近一条"已推到手机、还没标记"的。"""
|
||||
from core import video_plan as vp
|
||||
if not values:
|
||||
return [] if how == "take" else None
|
||||
if mode == "phone":
|
||||
if how == "peek":
|
||||
return vp.peek_for_device(values, date)
|
||||
if how == "last":
|
||||
return vp.last_pushed_for_device(values)
|
||||
return vp.take_for_device(values, date, limit=limit, retry_failed=retry_failed)
|
||||
if how == "peek":
|
||||
return vp.peek_for_name(values, date)
|
||||
if how == "last":
|
||||
return vp.last_pushed_for_name(values)
|
||||
return vp.take_for_name(values, date, limit=limit, retry_failed=retry_failed)
|
||||
|
||||
def _verify_account_name(self, d, name):
|
||||
"""推送前**再读一次**手机屏幕上的昵称,核对是不是真的要发的那个名字。
|
||||
|
||||
返回 `(是否通过, 读到什么)`。**读不到 = 不通过**(宁可不发,也不发错号)。
|
||||
没记录昵称选择器时返回 `(True, "")` —— 那种情况下前面必然已经校验过
|
||||
(切号步骤切完会回读、读取名字步骤刚读过),这里不再重复读一次。
|
||||
|
||||
比对用**双向包含**:元素文本可能是 `小王`,也可能带前缀/后缀(`@小王`),
|
||||
只要有一边包含另一边就算对上 —— 但空文本永远不算对上。
|
||||
"""
|
||||
from core import video_plan as vp
|
||||
sel_type, sel_val = self._vars.get("name_selector") or ("", "")
|
||||
if not sel_val:
|
||||
return True, ""
|
||||
got = vp.clean_account_name(self._read_element_text(d, sel_type, sel_val))
|
||||
if not got:
|
||||
return False, ""
|
||||
return (name in got or got in name), got
|
||||
|
||||
def _release_candidates(self):
|
||||
"""条件判断的「候选值来源 = 发布计划」:本机要发的那条计划的 **抖音号 + 账号名称**。
|
||||
"""条件判断的「候选值来源 = 发布计划」:本机要发的那条计划的**名字**。
|
||||
|
||||
优先用本次运行里「推送发布视频」已经拿到的那条(`_last_release`);
|
||||
还没有就**看**下一条(peek,不占位)—— 这正是"推送前先校验账号对不对"的用法:
|
||||
命中(当前登录的就是要发的那个号)→ 走 then 去推送/发布;未命中 → else 跳过并通知。
|
||||
|
||||
给两个候选值是刻意的:元素原文可能是 `抖音号:35377983067`,也可能只显示昵称,
|
||||
两个都算命中(候选值之间是 OR)。配「包含」运算符即可。
|
||||
命中(当前登的就是要发的那个名字)→ 走 then 去推送/发布;未命中 → else 跳过并通知。
|
||||
配「包含」运算符即可。
|
||||
"""
|
||||
from core import video_plan as vp
|
||||
plan = getattr(self, "_last_release", None)
|
||||
src = "本次已推送的那条"
|
||||
if not plan:
|
||||
phones, _who = self._release_targets({})
|
||||
plan = vp.peek_for_device(phones, vp._today()) if phones else None
|
||||
mode, values, _who = self._release_scope({})
|
||||
plan = self._release_fetch(mode, values, vp._today(), how="peek")
|
||||
src = "本机下一条待发"
|
||||
if not plan:
|
||||
return [], "本机没有待发的发布计划"
|
||||
cands = [c for c in (plan.get("douyin_id"), plan.get("nickname")) if c]
|
||||
note = (f"{src}计划的抖音号 {plan.get('douyin_id')}"
|
||||
f"({plan.get('nickname') or plan.get('phone')} "
|
||||
f"{plan.get('release_date')} #{plan.get('seq')})")
|
||||
cands = [c for c in (plan.get("nickname"),) if c]
|
||||
note = (f"{src}计划的名字『{plan.get('nickname')}』"
|
||||
f"({plan.get('release_date')} #{plan.get('seq')})")
|
||||
return cands, note
|
||||
|
||||
def _exec_read_account_name(self, d, params, depth=0):
|
||||
"""读取本机当前登录的账号名字(抖音「我」页面上的昵称控件)。
|
||||
|
||||
参数:`selector_type`(默认 xpath) `selector_value`(**昵称控件,先抓一次**)
|
||||
`timeout`(8) `on_missing`(`fail` 默认 = 读不到就算这一步失败 | `skip` = 记一句继续)
|
||||
`probability`
|
||||
|
||||
读到的东西写进 `self._vars`:
|
||||
· `account_name` = 清洗后的名字(`core.video_plan.clean_account_name`)
|
||||
· `name_selector` = 用的选择器 —— 「推送发布视频」推送前**用同一个控件**再读一次核对
|
||||
|
||||
为什么单独一步而不是藏在推送里:后面的「条件判断」要拿这个名字当候选值
|
||||
(`cmp_source=account_target`),推送前还要核对 —— 三处必须**同一个控件、同一套清洗**,
|
||||
所以选择器与清洗规则都收在这里。读到什么会写进「步骤明细」,
|
||||
名字对不上时一眼能看出"读到的到底是什么"。
|
||||
|
||||
返回 True=读到 / False=读不到(`on_missing=fail`)/ None=读不到但 `on_missing=skip`。
|
||||
"""
|
||||
from core import video_plan as vp
|
||||
sel_type = (params.get("selector_type") or "xpath").strip()
|
||||
sel_val = (params.get("selector_value") or "").strip()
|
||||
timeout = float(params.get("timeout") or 8)
|
||||
soft = (params.get("on_missing") or "fail").strip().lower() == "skip"
|
||||
if not sel_val:
|
||||
msg = ("昵称控件的选择器是空的 —— 用「抓取元素」抓一下抖音「我」页面上的昵称,"
|
||||
"在「读取本机账号名字」和后面的「条件判断」里都填上它")
|
||||
_log.error(f"[{self.serial}] {msg}")
|
||||
self.set_action(msg)
|
||||
return None if soft else False
|
||||
raw = ""
|
||||
try:
|
||||
if sel_type == "xpath":
|
||||
el = d.xpath(sel_val)
|
||||
if el.wait(timeout=timeout):
|
||||
raw = el.get_text() or ""
|
||||
else:
|
||||
el = d(**{sel_type: sel_val})
|
||||
if el.exists(timeout=timeout):
|
||||
raw = el.get_text(timeout=_TEXT_READ_TIMEOUT) or ""
|
||||
except Exception as e:
|
||||
_log.warning(f"[{self.serial}] 读取账号名字失败: {e}")
|
||||
name = vp.clean_account_name(raw)
|
||||
self._vars["name_selector"] = (sel_type, sel_val)
|
||||
if not name:
|
||||
_log.warning(f"[{self.serial}] 读取账号名字:读不到(元素原文 {raw!r})→ "
|
||||
f"本机算**没有号**:条件判断会走 else、推送会跳过(不发)")
|
||||
self.set_action("读不到本机账号名字(不发)")
|
||||
return None if soft else False
|
||||
self._vars["account_name"] = name
|
||||
_log.info(f"[{self.serial}] 本机账号名字 = 『{name}』(元素原文 {raw!r})")
|
||||
self.set_action(f"本机账号:{name}")
|
||||
return True
|
||||
|
||||
# 抖音停在登录/验证码页的字样。**只在"切号后回读校验没过"时才拿它判定** ——
|
||||
# 平时查会把「我」页面上的普通文字误判成登录页,而误判会让整台设备停掉。
|
||||
_LOGIN_HINTS = ("验证码", "其他方式登录", "手机号登录", "一键登录", "密码登录",
|
||||
"登录/注册", "同意用户协议", "选择登录方式")
|
||||
|
||||
def _notify_account(self, event, want="", got="", attempts=0, msg=""):
|
||||
"""账号相关通知(切号失败 / 需要登录)。notify 零 DB,发不出去不影响任务。"""
|
||||
try:
|
||||
notifier.notify(event, serial=self.serial,
|
||||
device_name=(self.ctx or {}).get("device_name", ""),
|
||||
model=(self.ctx or {}).get("model", ""),
|
||||
want=want, got=got, attempts=attempts, msg=msg)
|
||||
except Exception as e:
|
||||
_log.warning(f"账号通知发送失败(不影响任务):{e}")
|
||||
|
||||
def _read_name_now(self, d, params):
|
||||
"""现在屏幕上读一次昵称(清洗后)。选择器:步骤参数优先,其次「读取本机账号名字」
|
||||
记下的那个;都没有 → 返回 ""(= 读不到,调用方按"没有号"处理)。"""
|
||||
from core import video_plan as vp
|
||||
sel_type = (params.get("name_selector_type") or "").strip()
|
||||
sel_val = (params.get("name_selector_value") or "").strip()
|
||||
if not sel_val:
|
||||
sel_type, sel_val = self._vars.get("name_selector") or ("", "")
|
||||
if not sel_val:
|
||||
return ""
|
||||
return vp.clean_account_name(self._read_element_text(d, sel_type or "xpath", sel_val))
|
||||
|
||||
def _click_sel(self, d, sel_type, sel_val, timeout=8):
|
||||
"""按选择器等元素出现并点击。找不到返回 False(不抛异常)。"""
|
||||
if not sel_val:
|
||||
return False
|
||||
try:
|
||||
if sel_type == "xpath":
|
||||
el = d.xpath(sel_val)
|
||||
if el.wait(timeout=timeout):
|
||||
el.click()
|
||||
return True
|
||||
return False
|
||||
el = d(**{sel_type: sel_val})
|
||||
if el.exists(timeout=timeout):
|
||||
el.click()
|
||||
return True
|
||||
except Exception as e:
|
||||
_log.warning(f"[{self.serial}] 点击失败({sel_type}={sel_val}): {e}")
|
||||
return False
|
||||
|
||||
def _click_in_list(self, d, name, timeout=4):
|
||||
"""在账号列表里**按名字**找控件并点击(文本优先,再试描述/精确文本)。
|
||||
|
||||
⚠ **绝不用 `[N]` 序号**:`doc/research/U2_ELEMENT_SELECTORS.md` 实测序号型会随界面
|
||||
变形错位 —— 多点一个就是把内容发到别人号上。
|
||||
"""
|
||||
for kw in ({"textContains": name}, {"descriptionContains": name}, {"text": name}):
|
||||
try:
|
||||
obj = d(**kw)
|
||||
if obj.exists(timeout=timeout):
|
||||
obj.click()
|
||||
return True
|
||||
except Exception as e:
|
||||
_log.debug(f"[{self.serial}] 列表里按 {kw} 找『{name}』失败: {e}")
|
||||
return False
|
||||
|
||||
def _switch_target(self, params):
|
||||
"""这一步切到哪个名字:`var:target_name`(默认,循环块设的目标)/
|
||||
`var:account_name`(刚读到的)/ `literal` + `target_value`。"""
|
||||
t = (params.get("target") or "var:target_name").strip()
|
||||
if t.startswith("var:"):
|
||||
key = t[4:].strip() or "target_name"
|
||||
val = (self._vars.get(key) or "").strip()
|
||||
if not val and key == "target_name":
|
||||
val = (self._vars.get("account_name") or "").strip()
|
||||
return val
|
||||
return (params.get("target_value") or "").strip()
|
||||
|
||||
def _page_has_text(self, d, words, timeout=0.4):
|
||||
"""页面上有没有这些字样(返回命中的词,都没有返回 "")。"""
|
||||
for w in words:
|
||||
try:
|
||||
if d(textContains=w).exists(timeout=timeout):
|
||||
return w
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
def _handle_login_page(self, target, hint, params):
|
||||
"""停在登录/验证码页:通知 + (默认)停本设备。**平台绝不代替登录/过滑块**。"""
|
||||
msg = f"抖音停在需要登录的页面(出现「{hint}」)—— 请人工登录后再跑"
|
||||
_log.error(f"[{self.serial}] {msg}(想切『{target}』)")
|
||||
self.set_action(f"需要人工登录({hint})→ 停本设备")
|
||||
self._notify_account("task.account.need_login", want=target, msg=msg)
|
||||
if (params.get("on_login_page") or "stop").strip().lower() != "continue":
|
||||
self.stop()
|
||||
return False
|
||||
|
||||
def _exec_switch_account(self, d, params, depth=0):
|
||||
"""切到本次要发的那个账号(**只在本机已登录过的号之间切**)。
|
||||
|
||||
参数:`target`(`var:target_name` 默认,或 `literal` + `target_value`)、
|
||||
`switch_selector_type`/`switch_selector_value`(进「账号切换」列表那一下,**待抓**)、
|
||||
`name_selector_*`(昵称控件,默认沿用「读取本机账号名字」记下的那个)、
|
||||
`me_selector_*`(底部「我」,默认 `descriptionContains=我`)、
|
||||
`timeout`(8) `verify`(True) `max_attempts`(2) `on_login_page`(stop)
|
||||
|
||||
流程:当前名字已经是目标 → 直接成功(第一个号不用切)→ 点「我」→ 点切换入口 →
|
||||
**在列表里按名字找控件点击** → 等待重载 → **回读校验**(`verify`)。
|
||||
|
||||
返回 True=已经是目标/切成功;False=没切成(本轮该号会被守卫挡下、**不推送**)。
|
||||
**列表里没有这个名字 = 本机没登这个号** → 立刻返回 False(不重试、不算故障):
|
||||
多台设备各自"试自己有的号"就靠这条,不会互相误报。
|
||||
停在登录/验证码页 → 通知 `task.account.need_login` + 默认停本设备。
|
||||
"""
|
||||
target = self._switch_target(params)
|
||||
if not target:
|
||||
_log.warning(f"[{self.serial}] 切换账号:没给目标名字 → 跳过(不发)")
|
||||
self.set_action("切号:没给目标名字 → 跳过")
|
||||
return False
|
||||
timeout = float(params.get("timeout") or 8)
|
||||
want_verify = params.get("verify", True) is not False
|
||||
attempts = max(1, int(params.get("max_attempts") or 2))
|
||||
got = self._read_name_now(d, params)
|
||||
if got and got == target:
|
||||
_log.info(f"[{self.serial}] 切换账号:当前就是『{target}』,不用切")
|
||||
self.set_action(f"已经是『{target}』,不用切")
|
||||
return True
|
||||
switch_val = (params.get("switch_selector_value") or "").strip()
|
||||
if not switch_val:
|
||||
msg = ("「进账号切换列表」的选择器是空的 —— 用「抓取元素」抓一下抖音里那个入口"
|
||||
"(「我」页面 → 切换账号),填到这一步上")
|
||||
_log.error(f"[{self.serial}] {msg}")
|
||||
self.set_action(msg)
|
||||
return False
|
||||
me_type = params.get("me_selector_type") or "descriptionContains"
|
||||
me_val = params.get("me_selector_value") or "我"
|
||||
last_got = ""
|
||||
for i in range(1, attempts + 1):
|
||||
if self.stopped():
|
||||
return False
|
||||
self._click_sel(d, me_type, me_val, timeout=min(timeout, 4))
|
||||
time.sleep(1.0)
|
||||
if not self._click_sel(d, params.get("switch_selector_type") or "xpath",
|
||||
switch_val, timeout):
|
||||
hint = self._page_has_text(d, self._LOGIN_HINTS)
|
||||
if hint:
|
||||
return self._handle_login_page(target, hint, params)
|
||||
_log.warning(f"[{self.serial}] 切换账号:进不去账号列表(第 {i}/{attempts} 次)")
|
||||
continue
|
||||
time.sleep(1.2)
|
||||
if not self._click_in_list(d, target, timeout=int(max(3, timeout))):
|
||||
# 可能是列表还没加载出来 —— 再等一次;还没有就是"本机没登这个号"
|
||||
time.sleep(1.5)
|
||||
if not self._click_in_list(d, target, timeout=3):
|
||||
hint = self._page_has_text(d, self._LOGIN_HINTS)
|
||||
if hint:
|
||||
return self._handle_login_page(target, hint, params)
|
||||
_log.warning(f"[{self.serial}] 切换账号:账号列表里没有『{target}』"
|
||||
f"→ 本机没登这个号,跳过(不重试)")
|
||||
self.set_action(f"本机没有『{target}』这个号 → 跳过")
|
||||
self._notify_account("task.account.switch_failed", want=target,
|
||||
msg="账号列表里没有这个名字(本机没登这个号)")
|
||||
return False
|
||||
time.sleep(2.5) # 等切换生效(抖音会重载「我」页面)
|
||||
if not want_verify:
|
||||
self.set_action(f"已点击『{target}』(未校验)")
|
||||
return True
|
||||
last_got = self._read_name_now(d, params)
|
||||
if last_got == target:
|
||||
_log.info(f"[{self.serial}] 切换账号成功:『{target}』(第 {i} 次)")
|
||||
self.set_action(f"已切到『{target}』")
|
||||
self._vars["account_name"] = target
|
||||
return True
|
||||
hint = self._page_has_text(d, self._LOGIN_HINTS) if not last_got else ""
|
||||
if hint:
|
||||
return self._handle_login_page(target, hint, params)
|
||||
_log.warning(f"[{self.serial}] 切换账号:点完『{target}』回读是"
|
||||
f"『{last_got or '读不到'}』(第 {i}/{attempts} 次)")
|
||||
self._notify_account("task.account.switch_failed", want=target, got=last_got,
|
||||
attempts=attempts,
|
||||
msg=f"切换后回读仍是『{last_got or '读不到'}』")
|
||||
self.set_action(f"切号失败:要『{target}』,读到『{last_got or '读不到'}』")
|
||||
return False
|
||||
|
||||
def _exec_push_release(self, d, params, depth=0):
|
||||
"""推送发布视频:把本机某天待发的计划**推到手机相册**(**不发布**)。
|
||||
|
||||
@@ -911,10 +1219,13 @@ class GenericStepsWorker(BaseWorker):
|
||||
抖音里怎么发(打开→点+→相册→选视频→下一步→粘贴标题→发布)由你在这条步骤
|
||||
**后面自己画步骤**,最后放一个「标记发布结果」回写状态。
|
||||
|
||||
参数:`date_source`(today/fixed) `fixed_date` `account`(device/phone) `account_phone`
|
||||
参数:`date_source`(today/fixed) `fixed_date` `account`(name/phone) `account_phone`
|
||||
`album_dir`(默认 `/sdcard/DCIM/Camera`)`to_clipboard`(True) `retry_failed`(True)
|
||||
`max_same_run`(1 —— 一次运行推几条;**多条时「标记」只认最后一条**,要发多条就重复"推送+标记"两步)
|
||||
|
||||
`account=name`(默认)时**推送前会再读一次屏幕上的昵称核对**:对不上(或读不到)
|
||||
就**不推**(宁可不发,也不发错号),返回 None 让后面按"没有计划"走。
|
||||
|
||||
返回 None=本机没有待推的(跳过);True/False=至少推成一条/全失败。
|
||||
"""
|
||||
from tasks.generic import publish_flow as flow
|
||||
@@ -922,21 +1233,30 @@ class GenericStepsWorker(BaseWorker):
|
||||
|
||||
date = ((params.get("fixed_date") or "").strip()
|
||||
if params.get("date_source") == "fixed" else vp._today()) or vp._today()
|
||||
phones, who = self._release_targets(params)
|
||||
if not phones:
|
||||
_log.warning(f"[{self.serial}] 推送发布视频:本机没有台账账号 → 跳过")
|
||||
self.set_action("本机没有台账账号 → 跳过")
|
||||
mode, values, who = self._release_scope(params)
|
||||
if not values:
|
||||
_log.warning(f"[{self.serial}] 推送发布视频:{who}")
|
||||
self.set_action(f"跳过:{who}")
|
||||
return None
|
||||
plans = vp.take_for_device(phones, date,
|
||||
limit=int(params.get("max_same_run") or 1),
|
||||
retry_failed=params.get("retry_failed", True) is not False)
|
||||
if mode == "name":
|
||||
ok, got = self._verify_account_name(d, values[0])
|
||||
if not ok:
|
||||
_log.warning(f"[{self.serial}] 推送发布视频:核对不过 —— 屏幕上是『{got or '读不到'}』,"
|
||||
f"要发的是『{values[0]}』**不推**(宁可不发,也不发错号)")
|
||||
self.set_action(f"跳过:当前账号『{got or '读不到'}』≠ 要发的『{values[0]}』")
|
||||
return None
|
||||
plans = self._release_fetch(mode, values, date, how="take",
|
||||
limit=int(params.get("max_same_run") or 1),
|
||||
retry_failed=params.get("retry_failed", True) is not False)
|
||||
if not plans:
|
||||
_log.info(f"[{self.serial}] 推送发布视频:{who} 在 {date} 没有待推送的计划 → 跳过")
|
||||
self.set_action(f"{date} 没有待推送计划")
|
||||
return None
|
||||
|
||||
opts = {"album_dir": (params.get("album_dir") or "").strip() or flow.ALBUM_DIR,
|
||||
"to_clipboard": params.get("to_clipboard", True) is not False}
|
||||
"to_clipboard": params.get("to_clipboard", True) is not False,
|
||||
# 记"这条实际是哪台设备发的"(计划不绑设备,只能推的时候现场记)
|
||||
"device_name": (self.ctx or {}).get("device_name", "")}
|
||||
okN = badN = 0
|
||||
for plan in plans:
|
||||
if self.stopped():
|
||||
@@ -988,8 +1308,8 @@ class GenericStepsWorker(BaseWorker):
|
||||
|
||||
plan = getattr(self, "_last_release", None)
|
||||
if not plan:
|
||||
phones, _who = self._release_targets(params)
|
||||
plan = vp.last_pushed_for_device(phones) if phones else None
|
||||
mode, values, _who = self._release_scope(params)
|
||||
plan = self._release_fetch(mode, values, how="last")
|
||||
if not plan:
|
||||
_log.warning(f"[{self.serial}] 标记发布结果:找不到对应的计划行 → 跳过"
|
||||
f"(前面要先有一个「推送发布视频」步骤)")
|
||||
@@ -1327,6 +1647,78 @@ class GenericStepsWorker(BaseWorker):
|
||||
# 扣掉这段时间后可能已经过点了,负数 sleep 会直接抛异常
|
||||
time.sleep(max(0.0, min(0.5, end - time.time())))
|
||||
|
||||
def _exec_foreach_account(self, d, params, depth=0):
|
||||
"""循环块:**一轮把本机待发的号跑完**(一直切号发,直到发完)。
|
||||
|
||||
参数:`date_source`(today/fixed)、`fixed_date`、`retry_failed`(True)、
|
||||
`max_accounts`(0=不限)、`children`(要重复跑的那段:切号 → 读名 → 守卫 → 推送 → 发布 → 标记)
|
||||
|
||||
循环口径(**只在这一处定义**):
|
||||
|
||||
· 数据源 = `vp.pending_names(date)` —— 计划表里**还有待发内容**的名字,
|
||||
按 `(发布日期, 编号)` 升序。**不需要任何账号台账**:谁还有待发内容谁就在列表里。
|
||||
· 外层:挑一个**还没试过**的名字 → 写 `_vars["target_name"]` → 跑 children。
|
||||
试过一次就不回头(`tried`)——"本机没登这个号"不会把整轮卡死。
|
||||
· 内层:这个名字**还有待发内容就一直发**(同一个号多条视频);
|
||||
跑完 children 后待发数没变(切号失败/守卫没过/什么都没占位)→ 立刻换下一个号,
|
||||
绝不空转。
|
||||
· 三个干净出口:`stopped()`(外部停止)/ `is_time_up()`(任务时长上限)/ `max_accounts`。
|
||||
|
||||
跑到一半被停是安全的:`claim()` 已经占位的行由僵尸回收落 `failed`/`unknown`,
|
||||
没跑到的号下一轮 `pending_names` 还会选中 —— **不用怕"没发完就走了"**。
|
||||
"""
|
||||
from core import video_plan as vp
|
||||
children = params.get("children") or []
|
||||
if not children:
|
||||
_log.warning(f"[{self.serial}] 循环本机待发的号:没有子步骤 → 直接结束")
|
||||
return None
|
||||
date = ((params.get("fixed_date") or "").strip()
|
||||
if params.get("date_source") == "fixed" else vp._today()) or vp._today()
|
||||
retry_failed = params.get("retry_failed", True) is not False
|
||||
max_accounts = int(params.get("max_accounts") or 0)
|
||||
tried, sent = [], 0
|
||||
while True:
|
||||
if self.stopped() or self.is_time_up():
|
||||
_log.info(f"[{self.serial}] 循环本机待发的号:停止/超时 → 收工(跑过 {sent} 个号)")
|
||||
break
|
||||
if max_accounts and sent >= max_accounts:
|
||||
_log.info(f"[{self.serial}] 循环本机待发的号:到 max_accounts={max_accounts} → 收工")
|
||||
break
|
||||
names = [x["name"] for x in vp.pending_names(date, retry_failed)
|
||||
if x["name"] not in tried]
|
||||
if not names:
|
||||
_log.info(f"[{self.serial}] 循环本机待发的号:没有待发的号了 → 收工"
|
||||
f"(跑过 {sent} 个:{'、'.join(tried) or '无'})")
|
||||
break
|
||||
target = names[0]
|
||||
tried.append(target) # 试过就不回头(防死循环)
|
||||
self._vars["target_name"] = target
|
||||
sent += 1
|
||||
self.set_action(f"第 {sent} 个号:{target}(本机还有 {len(names)} 个待发名字)")
|
||||
_log.info(f"[{self.serial}] 循环本机待发的号:轮到『{target}』"
|
||||
f"(本机还有 {len(names)} 个待发名字)")
|
||||
inner = 0
|
||||
while True:
|
||||
if self.stopped() or self.is_time_up():
|
||||
break
|
||||
before = vp.pending_count(target, date, retry_failed)
|
||||
if before <= 0:
|
||||
break
|
||||
self._exec_steps(d, children, depth + 1)
|
||||
after = vp.pending_count(target, date, retry_failed)
|
||||
if after >= before:
|
||||
# 这一轮一条都没占到(切号失败 / 守卫没过 / 推送没成)→ 别空转
|
||||
_log.info(f"[{self.serial}] 『{target}』这一轮没有进展(仍 {after} 条待发)"
|
||||
f"→ 换下一个号")
|
||||
break
|
||||
inner += 1
|
||||
if inner > 20: # 同一个号一次跑这么多轮显然不对(正常一个号几条)
|
||||
_log.warning(f"[{self.serial}] 『{target}』已跑 {inner} 轮 → 收手,换下一个号")
|
||||
break
|
||||
self._vars.pop("target_name", None)
|
||||
self.set_action(f"本轮跑过 {sent} 个号:{'、'.join(tried)}" if tried else "没有待发的号")
|
||||
return None
|
||||
|
||||
def _exec_loop(self, d, params, depth=0):
|
||||
children = params.get("children", [])
|
||||
if not children:
|
||||
@@ -1398,15 +1790,15 @@ class GenericStepsWorker(BaseWorker):
|
||||
from core import video_plan as vp
|
||||
plan = getattr(self, "_last_release", None)
|
||||
if not plan:
|
||||
phones, _who = self._release_targets(params)
|
||||
plan = vp.last_pushed_for_device(phones) if phones else None
|
||||
mode, values, _who = self._release_scope(params)
|
||||
plan = self._release_fetch(mode, values, how="last")
|
||||
if plan:
|
||||
text, _err = vp.build_caption(
|
||||
plan, params.get("release_topics") or "",
|
||||
key=RELEASE_TEXT_SOURCES[src])
|
||||
self._last_release = self._last_release or plan
|
||||
_log.info(f"[{self.serial}] 输入文字取计划{RELEASE_TEXT_SOURCES[src]}:"
|
||||
f"{plan.get('douyin_id') or plan.get('phone')} "
|
||||
f"{plan.get('nickname')} "
|
||||
f"{plan.get('release_date')} #{plan.get('seq')}")
|
||||
if not text:
|
||||
_log.warning(f"[{self.serial}] 输入文字:本机没有可用的发布计划内容 → 跳过"
|
||||
|
||||
Reference in New Issue
Block a user