发布计划不再绑设备、不再依赖账号台账:上传文件名 `名字_日期_编号`(标题行 `标题 || 正文_名字_日期_编号`),运行时读本机当前抖音昵称取计划;靠循环块 「一轮把本机待发的号跑完」自动切号,一直发到发完。 · 解析/配对换键:右锚定认名字(不许含下划线、不许纯数字 —— 不合法直接拒收并说清原因), 删掉三处"手机号必须命中台账"的校验,加索引 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(临时库 + 假设备,不碰真实数据、不连真机)
225 lines
11 KiB
Python
225 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""P2 测试:读名字 / 按名字取计划 / 推送前核对(**临时库 + 假设备**,不碰真实数据、不连真机)。"""
|
||
import io
|
||
import os
|
||
import shutil
|
||
import sys
|
||
import tempfile
|
||
|
||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||
|
||
TMP = tempfile.mkdtemp(prefix='ac_p2_')
|
||
os.environ['DATABASE_URL'] = 'sqlite:///' + os.path.join(TMP, 'ac.db').replace('\\', '/')
|
||
os.environ['DATA_VIDEO_DIR'] = os.path.join(TMP, 'videos')
|
||
os.environ['DEPLOY_ENV'] = 'dev'
|
||
os.environ['DB_ALLOW_SQLITE_FALLBACK'] = '1'
|
||
|
||
# 仓库根 = scripts/tests/ 往上三级(不写死绝对路径,换机器也能跑)
|
||
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
sys.path.insert(0, _ROOT)
|
||
os.chdir(_ROOT)
|
||
|
||
from flask import Flask # noqa: E402
|
||
from core import db_config, models # noqa: E402
|
||
from core import video_plan as vp # noqa: E402
|
||
from tasks.generic import task as gt # noqa: E402
|
||
from tasks.generic import publish_flow as flow # noqa: E402
|
||
|
||
fails = []
|
||
|
||
|
||
def check(label, got, want):
|
||
ok = got == want
|
||
if not ok:
|
||
fails.append(label)
|
||
print((' ✅ ' if ok else ' ❌ ') + label + ('' if ok else '\n 得到 %r\n 期望 %r' % (got, want)))
|
||
|
||
|
||
def ok(label, cond, detail=''):
|
||
if not cond:
|
||
fails.append(label)
|
||
print((' ✅ ' if cond else ' ❌ ') + label + ('' if cond else ' ' + str(detail)))
|
||
|
||
|
||
class StubEl:
|
||
"""假元素:u2 的 XPathSelector / UiObject 各用到的成员都给上。"""
|
||
|
||
def __init__(self, text):
|
||
self._t = text
|
||
|
||
@property
|
||
def exists(self):
|
||
return self._t is not None
|
||
|
||
def wait(self, timeout=0):
|
||
return self._t is not None
|
||
|
||
def get_text(self, timeout=None):
|
||
return self._t
|
||
|
||
|
||
class StubD:
|
||
def __init__(self, text):
|
||
self.text = text
|
||
|
||
def xpath(self, v):
|
||
return StubEl(self.text)
|
||
|
||
|
||
app = Flask("t")
|
||
uri = db_config.build_db_uri()
|
||
app.config["SQLALCHEMY_DATABASE_URI"] = uri
|
||
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = db_config.engine_options(uri)
|
||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||
models.db.init_app(app)
|
||
with app.app_context():
|
||
models.db.create_all()
|
||
vp.init_app(app)
|
||
|
||
print('=== 1. clean_account_name(纯函数)===')
|
||
check('只有名字', vp.clean_account_name('小王'), '小王')
|
||
check('多行:取第一行', vp.clean_account_name('小王\n编辑资料\n抖音号:35377983067'), '小王')
|
||
check('首行是标签 → 往下找', vp.clean_account_name('编辑资料\n小王'), '小王')
|
||
check('抖音号行跳过', vp.clean_account_name('抖音号:35377983067\n小王'), '小王')
|
||
check('统计行跳过', vp.clean_account_name('关注\n粉丝\n小王'), '小王')
|
||
check('名字里的双空格保留', vp.clean_account_name('有牛奶面包 你吃吗?'), '有牛奶面包 你吃吗?')
|
||
check('名字带表情保留', vp.clean_account_name('🐟小鱼'), '🐟小鱼')
|
||
check('「关注我领福利」不当标签', vp.clean_account_name('关注我领福利'), '关注我领福利')
|
||
check('读到空 → ""', vp.clean_account_name('编辑资料\n抖音号:123'), '')
|
||
check('None → ""', vp.clean_account_name(None), '')
|
||
check('首尾空白去掉', vp.clean_account_name(' 小王 '), '小王')
|
||
|
||
print('=== 2. 临时库里造两条计划(小王 / 小李)===')
|
||
with app.app_context():
|
||
def mk(name, date, seq, status=vp.ST_READY, title="标题"):
|
||
row = models.VideoPlan(id=name + str(seq) + date[-2:], nickname=name, phone="",
|
||
device_name="", douyin_id="", serial="", release_date=date,
|
||
seq=seq, seq_auto=False, title=title, body="",
|
||
video_file=f"2026-10/x{seq}.mp4", video_name="x.mp4",
|
||
status=status, created_at=vp._now(), updated_at=vp._now())
|
||
models.db.session.add(row)
|
||
mk('小王', '2026-09-12', 1)
|
||
mk('小王', '2026-09-13', 2) # 同一个号,后天那条
|
||
mk('小李', '2026-09-12', 1)
|
||
mk('小张', '2026-09-20', 1) # 未来:不该被取到
|
||
mk('小赵', '2026-09-10', 1, status=vp.ST_DONE) # 已发:不该被取到
|
||
models.db.session.commit()
|
||
|
||
print('=== 3. 按名字取计划 ===')
|
||
check('小王 在 09-12 有一条', [p['seq'] for p in vp.take_for_name(['小王'], '2026-09-12')], [1])
|
||
check('取 09-13 时,过期的那条先发', [p['seq'] for p in vp.take_for_name(['小王'], '2026-09-13')], [1])
|
||
check('小李 只认自己的', [p['nickname'] for p in vp.take_for_name(['小李'], '2026-09-12')],
|
||
['小李'])
|
||
check('没有的名字取不到', vp.take_for_name(['不存在'], '2026-09-12'), [])
|
||
check('名字为空不取(绝不猜)', vp.take_for_name([], '2026-09-12'), [])
|
||
check('未来的不发', vp.take_for_name(['小张'], '2026-09-12'), [])
|
||
check('已发的(done)不再入队', vp.take_for_name(['小赵'], '2026-09-12'), [])
|
||
check('peek 只看不占位', vp.peek_for_name(['小王'], '2026-09-12')['seq'], 1)
|
||
check('peek 不存在的名字 → None', vp.peek_for_name(['不存在'], '2026-09-12'), None)
|
||
|
||
print('=== 4. pending_names / pending_count(循环块的数据源)===')
|
||
# 同日期同编号时按名字的**码点**定序(只要确定就行,不追求拼音序):李 U+674E < 王 U+738B
|
||
check('有待发内容的名字(按最早日期,同日期按名字定序)',
|
||
[(x['name'], x['release_date'], x['seq'])
|
||
for x in vp.pending_names('2026-09-13')],
|
||
[('小李', '2026-09-12', 1), ('小王', '2026-09-12', 1)])
|
||
check('只看指定的名字', [x['name'] for x in vp.pending_names('2026-09-13', names=['小王'])],
|
||
['小王'])
|
||
check('小王 待发 2 条', vp.pending_count('小王', '2026-09-13'), 2)
|
||
check('小李 待发 1 条', vp.pending_count('小李', '2026-09-13'), 1)
|
||
check('已完成的名字不在列表里', [x['name'] for x in vp.pending_names('2026-09-12', names=['小赵'])],
|
||
[])
|
||
check('未来日期还没到 → 今天不列', vp.pending_count('小张', '2026-09-12'), 0)
|
||
|
||
print('=== 5. 读名字步骤(假设备)===')
|
||
ctx = {"job_id": "t1", "job_name": "测试"}
|
||
w = gt.GenericStepsWorker("FAKE01", {}, ctx=ctx)
|
||
el_raw = '小王\n编辑资料\n抖音号:35377983067'
|
||
r = w._exec_read_account_name(StubD(el_raw), {"selector_type": "xpath",
|
||
"selector_value": "//x", "timeout": 1})
|
||
check('读到名字 → True', r, True)
|
||
check('_vars.account_name', w._vars.get('account_name'), '小王')
|
||
check('_vars.name_selector 记下了', w._vars.get('name_selector'), ('xpath', '//x'))
|
||
|
||
w2 = gt.GenericStepsWorker("FAKE02", {}, ctx=ctx)
|
||
r2 = w2._exec_read_account_name(StubD('编辑资料\n抖音号:123'),
|
||
{"selector_type": "xpath", "selector_value": "//x", "timeout": 1})
|
||
check('读不到(on_missing=fail)→ False', r2, False)
|
||
check('读不到时不写 account_name', w2._vars.get('account_name'), None)
|
||
|
||
w3 = gt.GenericStepsWorker("FAKE03", {}, ctx=ctx)
|
||
r3 = w3._exec_read_account_name(StubD('编辑资料'), {"selector_type": "xpath",
|
||
"selector_value": "//x",
|
||
"on_missing": "skip"})
|
||
check('on_missing=skip → None(不失败)', r3, None)
|
||
|
||
w4 = gt.GenericStepsWorker("FAKE04", {}, ctx=ctx)
|
||
r4 = w4._exec_read_account_name(StubD('小王'), {"selector_type": "xpath",
|
||
"selector_value": ""})
|
||
check('选择器为空 → False(并提示去抓元素)', r4, False)
|
||
|
||
print('=== 6. _release_scope:这次发的是哪个号 ===')
|
||
check('默认按名字(读到谁就是谁)', w._release_scope({}), ('name', ['小王'], '名字『小王』'))
|
||
check('没有名字 → 空值(跳过,不猜)', w2._release_scope({}),
|
||
('name', [], '没读到本机账号名字(前面要有「读取本机账号名字」,'
|
||
'或由循环块设目标名字)→ 跳过'))
|
||
check('account=phone 走老路径', w._release_scope({'account': 'phone',
|
||
'account_phone': '13300000000'}),
|
||
('phone', ['13300000000'], '指定手机号 13300000000'))
|
||
check('cycle 设的目标名字优先', (w._vars.__setitem__('target_name', '小李'),
|
||
w._release_scope({}))[1], ('name', ['小李'], '名字『小李』'))
|
||
w._vars.pop('target_name')
|
||
|
||
print('=== 7. 条件判断候选值 ===')
|
||
txt, note = w._resolve_cmp_value({'cmp_source': 'account_target'}, '')
|
||
check('account_target → 候选=名字', txt, '小王')
|
||
ok('说明里写了名字', '小王' in note, note)
|
||
txt2, _ = w2._resolve_cmp_value({'cmp_source': 'account_target'}, '')
|
||
check('读不到名字 → 候选为空(永远不命中 → 不发)', txt2, '')
|
||
txt3, _ = w2._resolve_cmp_value({'cmp_source': 'account_target', 'cmp_value': '手填的'}, '手填的')
|
||
check('读不到名字时手填值仍然生效', txt3, '手填的')
|
||
|
||
print('=== 8. 推送前核对(假设备,push_one 打桩)===')
|
||
calls = []
|
||
orig = flow.push_one
|
||
|
||
def fake_push(serial, plan, opts, log=None):
|
||
calls.append((serial, plan['id']))
|
||
return True, "ok"
|
||
|
||
flow.push_one = fake_push
|
||
try:
|
||
# 8a 屏幕上不是我们要发的人 → 不推
|
||
w._vars['account_name'] = '小王'
|
||
w._vars['name_selector'] = ('xpath', '//x')
|
||
bad = w._exec_push_release(StubD('小李'), {'account': 'name',
|
||
'date_source': 'fixed',
|
||
'fixed_date': '2026-09-12'})
|
||
check('核对不过 → 不推(返回 None)', bad, None)
|
||
check('push_one 一次都没调', calls, [])
|
||
|
||
# 8b 屏幕上就是本人 → 推
|
||
good = w._exec_push_release(StubD('小王'), {'account': 'name',
|
||
'date_source': 'fixed',
|
||
'fixed_date': '2026-09-12'})
|
||
check('核对通过 → 推成功', good, True)
|
||
check('push_one 被调了 1 次', len(calls), 1)
|
||
check('推的是小王那条', calls[0][1], '小王112')
|
||
|
||
# 8c 读不到名字 → 不推
|
||
calls.clear()
|
||
w2._vars['name_selector'] = ('xpath', '//x')
|
||
none_ = w2._exec_push_release(StubD('编辑资料'), {'date_source': 'fixed',
|
||
'fixed_date': '2026-09-12'})
|
||
check('读不到名字 → 不推', none_, None)
|
||
check('push_one 没被调', calls, [])
|
||
finally:
|
||
flow.push_one = orig
|
||
|
||
print()
|
||
shutil.rmtree(TMP, ignore_errors=True)
|
||
if fails:
|
||
print('❌ 失败 %d 项:%s' % (len(fails), ' / '.join(fails)))
|
||
sys.exit(1)
|
||
print('✅ 全部通过(临时库已清理)')
|