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:
2026-10-12 09:02:40 +08:00
parent 75bee21ffb
commit beda0f3779
37 changed files with 2615 additions and 1747 deletions
+443 -51
View File
@@ -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}] 输入文字:本机没有可用的发布计划内容 → 跳过"