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:
@@ -0,0 +1,158 @@
|
||||
"""实时探测"这台设备现在登的是哪个抖音号"(**按名字找设备**用)。
|
||||
|
||||
**谁用它**:人工入口 —— 「发布计划」页的「一键推送(按名字找设备)」要先知道
|
||||
"哪台在线设备现在登着这个名字",才能把素材推过去。
|
||||
**自动任务不靠它**:任务里的「读取本机账号名字」永远是现场读屏幕(缓存旧了也不能影响发布正确性)。
|
||||
|
||||
它只做**只读/幂等**的动作:连设备 → 需要时把抖音拉到前台 → 点「我」→ 读昵称控件 → 清洗。
|
||||
**绝不登录、绝不滑块、绝不改设备设置**;连不上/读不到只报那一台,不影响别的设备。
|
||||
也绝不 `kill-server` / `disconnect`(技术约束红线)。
|
||||
|
||||
昵称控件在 `config.DOUYIN_NAME_SELECTOR_VALUE`(**抓一次填 .env**,与任务骨架同一处配置);
|
||||
没填就明确报"还没抓",不去猜。
|
||||
"""
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from config import DOUYIN_NAME_SELECTOR_TYPE, DOUYIN_NAME_SELECTOR_VALUE
|
||||
from core import u2_helper
|
||||
from core.logger import get_logger
|
||||
|
||||
_log = get_logger("core.account_probe")
|
||||
|
||||
DOUYIN_PKG = "com.ss.android.ugc.aweme"
|
||||
_CONNECT_TIMEOUT = 20 # u2.connect 超时(秒);设备端 u2 服务没响应时别把请求挂死
|
||||
_STEP_WAIT = 2.5 # 进「我」页面后等它渲染完
|
||||
|
||||
|
||||
def _connect(serial, timeout=_CONNECT_TIMEOUT):
|
||||
"""带超时的 u2.connect。
|
||||
|
||||
⚠ 用**手动 shutdown(wait=False)** —— `with ThreadPoolExecutor(...)` 退出时会
|
||||
`shutdown(wait=True)`,超时线程不死它就跟着等,30 秒的"超时"会变成真卡住
|
||||
(`core/device_worker` 里踩过这个坑)。
|
||||
"""
|
||||
import uiautomator2 as u2
|
||||
pool = ThreadPoolExecutor(max_workers=1)
|
||||
try:
|
||||
return pool.submit(u2.connect, serial).result(timeout=timeout)
|
||||
finally:
|
||||
pool.shutdown(wait=False)
|
||||
|
||||
|
||||
def probe_one(serial, click_me=True):
|
||||
"""读一台设备当前登录的抖音名字。返回 `{serial, name, error}`。
|
||||
|
||||
`name` 非空 = 读到了;`error` 非空 = 没读到(人话原因,给界面直接显示)。
|
||||
"""
|
||||
if not DOUYIN_NAME_SELECTOR_VALUE:
|
||||
return {"serial": serial, "name": "",
|
||||
"error": "还没抓抖音昵称控件:在 .env 里填 DOUYIN_NAME_SELECTOR_VALUE"
|
||||
"(抓取元素抓一下「我」页面上的昵称)"}
|
||||
d = None
|
||||
try:
|
||||
d = _connect(serial)
|
||||
except Exception as e:
|
||||
return {"serial": serial, "name": "",
|
||||
"error": f"连不上设备(u2 {_CONNECT_TIMEOUT}s 超时):{type(e).__name__}"}
|
||||
try:
|
||||
pkg = u2_helper.current_package(d)
|
||||
if pkg != DOUYIN_PKG:
|
||||
# 不在抖音 → 拉到前台(幂等:已经在就什么都不做)
|
||||
try:
|
||||
d.app_start(DOUYIN_PKG, wait=True)
|
||||
except Exception as e:
|
||||
return {"serial": serial, "name": "",
|
||||
"error": f"打不开抖音:{type(e).__name__}: {str(e)[:60]}"}
|
||||
time.sleep(4)
|
||||
if click_me:
|
||||
u2_helper.find_and_click(d, timeout=3,
|
||||
**{"descriptionContains": "我"})
|
||||
time.sleep(_STEP_WAIT)
|
||||
from core import video_plan as vp
|
||||
if DOUYIN_NAME_SELECTOR_TYPE == "xpath":
|
||||
el = d.xpath(DOUYIN_NAME_SELECTOR_VALUE)
|
||||
raw = el.get_text() if el.exists else ""
|
||||
else:
|
||||
el = d(**{DOUYIN_NAME_SELECTOR_TYPE: DOUYIN_NAME_SELECTOR_VALUE})
|
||||
raw = el.get_text(timeout=3) if el.exists(timeout=3) else ""
|
||||
name = vp.clean_account_name(raw)
|
||||
if not name:
|
||||
return {"serial": serial, "name": "",
|
||||
"error": f"读到空(元素原文 {raw!r})—— 昵称控件的选择器可能抓错了"}
|
||||
return {"serial": serial, "name": name, "error": ""}
|
||||
except Exception as e:
|
||||
return {"serial": serial, "name": "",
|
||||
"error": f"读取失败:{type(e).__name__}: {str(e)[:80]}"}
|
||||
finally:
|
||||
# **不 disconnect、不 kill-server**(红线):u2 的连接自己会超时回收
|
||||
pass
|
||||
|
||||
|
||||
def busy_serials():
|
||||
"""正在跑任务/正在连接的设备(探测要跳过它们:抢屏会打断任务)。"""
|
||||
from core import device_worker
|
||||
out = set()
|
||||
try:
|
||||
for s in device_worker.get_all_worker_status():
|
||||
if (s.get("status") or "") in ("running", "connecting"):
|
||||
out.add(s.get("serial") or "")
|
||||
except Exception as e:
|
||||
_log.warning(f"读设备运行状态失败(按「都不忙」处理): {e}")
|
||||
return {x for x in out if x}
|
||||
|
||||
|
||||
def probe_online(serials=None, skip_busy=True, workers=4):
|
||||
"""并行探测多台设备。返回 `{serial: {serial, name, error}}`。
|
||||
|
||||
· `serials=None` → 取设备池里**在线且启用**的设备(`device_pool.list_ready()`)
|
||||
· `skip_busy=True`(默认)→ 跳过正在跑任务/连接中的设备(别抢它们的屏)
|
||||
· 单台失败只影响那一台;顺序按传入/池里的顺序
|
||||
"""
|
||||
from core import device_pool
|
||||
if serials is None:
|
||||
try:
|
||||
serials = list(device_pool.list_ready())
|
||||
except Exception as e:
|
||||
_log.warning(f"取在线设备失败: {e}")
|
||||
return {}
|
||||
serials = [s for s in (serials or []) if s]
|
||||
if skip_busy:
|
||||
busy = busy_serials()
|
||||
if busy:
|
||||
_log.info(f"探测跳过正在跑任务的设备:{'、'.join(sorted(busy))}")
|
||||
serials = [s for s in serials if s not in busy]
|
||||
out = {}
|
||||
if not serials:
|
||||
return out
|
||||
with ThreadPoolExecutor(max_workers=max(1, min(workers, len(serials)))) as pool:
|
||||
for r in pool.map(probe_one, serials):
|
||||
out[r["serial"]] = r
|
||||
return out
|
||||
|
||||
|
||||
def cache_names(result):
|
||||
"""把探测结果写进 `device.cur_account_name/cur_name_at`(**只用于人工入口的设备建议**)。
|
||||
|
||||
⚠ 必须在 **Flask app context** 里调用(web 请求线程本身就有;脚本里自己推)。
|
||||
探测失败的那台**不清空旧值**(旧值好歹是个线索,界面上带时间戳显示)。
|
||||
"""
|
||||
from core.models import Device, db
|
||||
try:
|
||||
now = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
for serial, r in (result or {}).items():
|
||||
name = (r or {}).get("name") or ""
|
||||
if not name:
|
||||
continue
|
||||
row = Device.query.get(serial)
|
||||
if not row:
|
||||
continue
|
||||
row.cur_account_name = name[:80]
|
||||
row.cur_name_at = now
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
_log.warning(f"写设备当前账号缓存失败(不影响探测结果): {e}")
|
||||
try:
|
||||
db.session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user