7 Commits
Author SHA1 Message Date
butubb 31c97ea49c Merge branch 'dev'——发布计划标题+正文(|| 分隔)+ 发布流程多一步填正文;账号名称全面下线 2026-10-11 16:37:33 +08:00
butubb 75bee21ffb Merge branch 'feat/plan-title-body'——发布计划标题+正文(|| 分隔)+ 发布流程多一步填正文;账号名称全面下线 2026-10-11 16:37:32 +08:00
butubb 6ee089e023 feat(发布计划): 标题 + 正文(标题 || 正文 _手机号_日期_编号)+ 发布流程多一步填正文;账号名称全面下线
一、正文(video_plan.body,schema v10→v11)
- 标题 txt 一行里可带正文,用 `||` 分隔(标题/正文都常带下划线,不能再用下划线分列;
  只切第一处,正文里再出现 `||` 原样保留):`标题 || 正文 _手机号_日期_编号`
  正文可省(老文件照收);两种写法(标题在前 / 手机号在前)都能带正文
- 长度:标题 ≤100 字、正文 ≤1000 字,超了**拒收不截断**
- 「待发布」判据从"有没有标题"改成"**标题或正文有没有内容**"(`_sync_content_status` 一处定义,
  改标题/改正文/批量导入三处共用),只给正文的行也照样待发
- 导入配对:同标题补一条新正文**要收下**(不能当重复跳过);已有别的标题仍然拒收(不静默覆盖)

二、发布流程多一步
- 骨架 15 → **17 步**:⑪ 输入文字(计划的标题) → ⑫ 点击正文框 → ⑬ 输入文字(计划的正文) → ⑭ 发布 → ⑮ 标记
- 「输入文字」取值来源集中到 `RELEASE_TEXT_SOURCES`:release_title(_topics) / release_body(_topics) /
  release_title_body(标题+正文,只有一个输入框的抖音版本用);`build_caption(key=…)` 支持三种来源
- 任务编辑器与发布计划就地编辑器的下拉都补齐这 6 档;待抓的空选择器 5 → 6 个(多了正文框)

三、账号名称下线(用户要求:只要抖音号)
- 发布计划:卡片 / 明细表 / 搜索 / 导出 CSV 都不再有账号名称(CSV 改为
  发布日期/设备号/手机号/抖音号/编号/标题/正文/分享链接/发布时间)
- 账号台账:列表列 / 编辑弹窗 / 粘贴导入预览 / 设备维度弹窗 / 导入说明与示例都去掉;
  **粘贴导入忽略这一列**(不报错、不落库),库里的历史值保留(不删列)
- 设备端 Agent 身份页:平台不再下发名称(传空串,**不改 APK 也能生效**,避免老版本解析失败)

四、文档 / 测试
- DATA_MODEL:body 列 + schema 11;API:upload_titles(`||` 语义)、PUT {title,body}、CSV 列、骨架 17 步
- TASK_DEV:骨架 17 步、`input_text` 六个取值来源表、正文长度上限
- ARCHITECTURE:release.js 说明(正文、不显示账号名称)
- 测试(%TEMP% 被系统清理后重建):test_plan_body.py 33 项(解析/取内容/骨架/入库/导入配对)、
  smoke_app_body.py 10 项(整机装配:页面、PUT 改正文、时间线、CSV 表头)—— 全过
2026-10-11 16:36:56 +08:00
butubb 8a6d2845e7 Merge branch 'dev'——标题行两种写法都认 + 行首 # 不再无条件当注释 2026-09-29 09:00:56 +08:00
butubb a800d051ae feat(发布计划): 标题行两种写法都认 + 行首 # 不再无条件当注释
一、标题行解析(core/video_plan.parse_title_line)
- 原来只认「标题内容_手机号_日期_编号」(右锚定)。现在**两种都认**:
   ① 标题内容_手机号_日期_编号        (推荐,右锚定 —— 标题里带下划线也不会错配)
   ② 手机号_日期_编号_标题            (左锚定,跟视频文件名同序 = "文件名去掉扩展名 + 标题")
  两种里都**先试带编号的**,保证同一行永远只有一种解释;编号都可省略。
- 新拒收一条:`手机号_日期_1`(只有编号、没标题)**直接拒** ——
  不能把它当标题"1"(静默生成一条标题是"1"的文案,比拒收危险得多);
  标题真是纯数字的用写法①。

二、`#` 开头的行(parse_titles_text)
- 原来是"以 `#` 开头就整行忽略" → **`#中秋快乐_...` 这种正常标题会被静默丢掉**。
- 改成**先按标题行解析,解析得出就当标题;解析不出且以 `#` 开头才算注释**。
  `# 这是注释` 照样忽略,`#话题` 开头的标题照收(`#` 保留在标题里)。

三、其它
- 文案同步:上传标题面板/帮助文案、doc/API.md 的 upload_titles 语义(两种写法、`#` 规则、拒收条件)
- 测试:新增 8 条(两种写法 × 带/不带编号 × 标题含下划线与 #话题、只有编号要拒收、整段注释与报错)
2026-09-29 09:00:43 +08:00
butubb 881bf79417 Merge branch 'dev'——视频发布计划(批量上传配对 → 时间线 → 推送到手机 → 发布任务 → 分享链接)+ 修推送检测机制 2026-09-28 15:59:39 +08:00
butubb 72ea3d4894 Merge branch 'dev'——账号台账(web 页 + 任务取号 + 设备端身份页)+ 剪贴板注入改走设备端 Agent 2026-09-24 16:16:09 +08:00
13 changed files with 240 additions and 105 deletions
+7 -1
View File
@@ -329,7 +329,9 @@ def agent_accounts(serial="", device_name="", limit=50, include_phone=False):
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 "",
# name 恒空串:账号名称已停用(设备端 APK 仍会解析这个字段,
# 直接删 key 有解析失败的风险,所以留空串)
item = {"name": "", "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"))}
@@ -465,6 +467,10 @@ def parse_paste(text, delimiter="\t"):
row[f] = ""
for field, idx in mapped.items():
if field in FIELDS:
# 账号名称(nickname)**不再收录**:界面上已经去掉这一列,
# 老表格粘进来也不报错,只是这一列的值被忽略(库里的历史值不动)
if field == "nickname":
continue
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": "设备号与抖音号都是空的,已跳过"})
+5 -2
View File
@@ -525,7 +525,8 @@ class VideoPlan(db.Model):
release_date = db.Column(db.String(10), default="", index=True) # "YYYY-MM-DD"(纯日期,等值比较)
seq = db.Column(db.Integer, default=1) # 编号,从 1 起(不用 0 表示"无")
seq_auto = db.Column(db.Boolean, default=True) # 编号是自动分配出来的(界面要提示)
title = db.Column(db.Text, default="") # 文案
title = db.Column(db.Text, default="") # 标题(抖音发布页的「标题」框)
body = db.Column(db.Text, default="") # 正文/文案(发布页的文案框;标题 txt 里用 || 分隔)
video_file = db.Column(db.String(120), default="") # 平台落盘文件名(不含绝对路径)
video_name = db.Column(db.String(200), default="") # 原始上传名(排查用)
video_size = db.Column(db.Integer, default=0)
@@ -557,7 +558,8 @@ class VideoPlan(db.Model):
"nickname": self.nickname or "", "douyin_id": self.douyin_id or "",
"serial": self.serial or "", "release_date": self.release_date or "",
"seq": int(self.seq or 1), "seq_auto": bool(self.seq_auto),
"title": self.title or "", "video_file": self.video_file or "",
"title": self.title or "", "body": self.body or "",
"video_file": self.video_file or "",
"video_name": self.video_name or "", "video_size": int(self.video_size or 0),
"video_sha1": self.video_sha1 or "", "status": self.status or "",
"stage": self.stage or "", "attempts": int(self.attempts or 0),
@@ -600,6 +602,7 @@ SCHEMA_MIGRATIONS = [
(9, "视频发布计划:video_plan 表(账号×发布日期×编号 → 素材 + 标题 + 发布状态 + 分享链接)", None),
(10, "视频发布计划:video_plan 新增 push_verify(推送后相册校验:ok/no_index/nofile)"
"与 push_remote(手机上的绝对路径,删它/排查用)", None),
(11, "视频发布计划:video_plan 新增 body(正文)—— 标题 txt 一行里用 || 分隔「标题 || 正文」", None),
]
# 当前 schema 版本(备份/恢复用它判断新旧,也写进 app_meta.schema_version)
+109 -28
View File
@@ -60,6 +60,7 @@ STAGE_STATUS = {"push": ST_FAILED, "scan": ST_FAILED,
VIDEO_EXTS = (".mp4", ".mov", ".mkv", ".avi", ".3gp", ".flv", ".wmv", ".m4v", ".ts", ".webm")
MAX_TITLE_LEN = 100 # 超过就拒收(**不静默截断**:截断会发出错文案,用户看不见)
MAX_BODY_LEN = 1000 # 正文上限(抖音文案框放得下 1000 字;同样不截断,超了拒收)
MAX_ATTEMPTS = 3 # 尝试上限,超了不再自动取(人工确认后重试可清零)
ZOMBIE_MINUTES = 30 # 占位行超过这么久没动静 → 按 stage 降级(否则崩溃一次就永远发不出去)
VIDEO_KEEP_DAYS = 7 # 平台素材:抓到分享链接后再留这么多天
@@ -162,48 +163,90 @@ def parse_video_name(filename):
def parse_title_line(line):
"""标题行 `标题内容_手机号_日期_编号` → (meta, error)。标题里可以带下划线。
"""标题行 → (meta, error)。**两种写法都认**,标题里都可以带下划线。
右锚定 + 逐级回退(**必须先试带编号的**,否则带编号的行会被当成
"标题里含日期"而错配):
A) …_手机号_日期_编号 B) …_手机号_日期
**一行里可以有标题和正文**:`标题 || 正文 _手机号_日期_编号`(用 `||` 分隔,
因为标题/正文里常带下划线;只切第一处,正文里再出现 `||` 原样保留)。
没写 `||` 就是只有标题(老文件照收);` || 正文_…` 这种只有正文也行。
① 标题在前(推荐,右锚定):
A) 标题内容_手机号_日期_编号 B) 标题内容_手机号_日期
② 手机号在前(左锚定,跟视频文件名同序 —— "文件名去掉扩展名 + 标题"):
C) 手机号_日期_编号_标题 D) 手机号_日期_标题
为什么**先右后左**:右锚定是主格式(标题里带下划线也不会错配)。
左锚定的四种里先试带编号的 C(`手机号_日期_1_标题` —— 那个 `1` 当编号,
不当标题的开头),保证同一行永远只有一种解释。
什么时候两种都不会误判:右锚定要求"倒数第 3/2 段是手机号",左锚定要求
"第 1 段是手机号、第 2 段是日期" —— 一行同时满足两边时(例如 `手机号_日期_1`)
右锚定先命中,且标题为空会被下面的校验拒掉,不会静默发出一条错文案。
"""
t = (line or "").strip()
if not t:
return None, "空行"
parts = t.split("_")
phone = date = seq = None
title_parts = None
# ① 右锚定:先试带编号的,再试不带编号的
if len(parts) >= 4 and _SEQ_RE.match(parts[-1]) \
and _DATE8_RE.match(parts[-2]) and _PHONE_RE.match(parts[-3]):
seq = int(parts[-1])
phone, date8, title_parts = parts[-3], parts[-2], parts[:-3]
elif len(parts) >= 3 and _DATE8_RE.match(parts[-1]) and _PHONE_RE.match(parts[-2]):
phone, date8, title_parts = parts[-2], parts[-1], parts[:-2]
# ② 左锚定(手机号在前,跟视频文件名同序):同样先试带编号的
elif len(parts) >= 4 and _PHONE_RE.match(parts[0]) and _DATE8_RE.match(parts[1]) \
and _SEQ_RE.match(parts[2]):
phone, date8, seq = parts[0], parts[1], int(parts[2])
title_parts = parts[3:]
elif len(parts) >= 3 and _PHONE_RE.match(parts[0]) and _DATE8_RE.match(parts[1]):
# `手机号_日期_1` 这种"只有编号、没有标题"要**拒掉**,不能被当成标题"1"
# (静默生成一条标题是"1"的文案,比拒收危险得多;标题真是纯数字就用"标题在前"的写法)
if len(parts) == 3 and _SEQ_RE.match(parts[2]):
return None, ("只有手机号/日期/编号、没有标题文字 ——"
"标题是纯数字的话请用「标题_手机号_日期」写法")
phone, date8, title_parts = parts[0], parts[1], parts[2:]
else:
return None, "格式不对,应为 标题内容_手机号_日期_编号(编号可省略)"
return None, ("格式不对,应为「标题内容_手机号_日期_编号」"
"或「手机号_日期_编号_标题」(编号都可省略)")
d = parse_date8(date8)
if not d:
return None, f"日期 {date8} 不是合法日期(应为 20260912 这种 8 位)"
date = d
title = "_".join(title_parts).strip()
if not title:
return None, "缺标题内容(行首就是手机号)"
raw = "_".join(title_parts).strip()
# 「标题 || 正文」:用 `||` 分隔(标题/正文里常带下划线,所以不能再用下划线分列)。
# 只切第一处:正文自己再出现 `||` 也原样保留。没写 `||` 就是只有标题(老文件照收)。
title, body = raw, ""
if "||" in raw:
title, body = (x.strip() for x in raw.split("||", 1))
if not title and not body:
return None, "缺标题/正文内容(只有手机号/日期/编号,没有文字)"
if len(title) > MAX_TITLE_LEN:
return None, f"标题太长({len(title)} 字 > {MAX_TITLE_LEN} 字上限),请改短或拆成两条"
return {"title": title, "phone": phone, "date": date,
if len(body) > MAX_BODY_LEN:
return None, f"正文太长({len(body)} 字 > {MAX_BODY_LEN} 字上限),请改短或拆成两条"
return {"title": title, "body": body, "phone": phone, "date": date,
"seq": seq, "seq_given": seq is not None}, ""
def parse_titles_text(text):
"""标题 txt 全文 → (rows, errors)。`#` 开头的行与空行自动跳过(不算错)。"""
"""标题 txt 全文 → (rows, errors)。空行跳过;`#` 开头**只有认不出标题行时**才算注释。
⚠ 顺序是"先当标题解析、再当注释":`#中秋快乐_19286456013_20260929` 是一条**正常标题**
(`#话题` 本来就长这样),过去的写法会把整行当注释**静默丢掉**。现在只有像
`# 这是注释` 这种解析不出手机号/日期的行才按注释忽略。
"""
rows, errors = [], []
lines = (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
for i, raw in enumerate(lines, start=1):
s = raw.strip()
if not s or s.startswith("#"):
if not s:
continue
meta, err = parse_title_line(s)
if err:
if s.startswith("#"): # 认不出 + 以 # 开头 → 当注释,静默跳过
continue
errors.append({"line": i, "reason": err, "raw": s[:60]})
continue
meta["_line"] = i
@@ -327,12 +370,21 @@ def pair_slots(videos, titles, accounts_by_phone, db_taken=None):
return pairs, rejected
def build_caption(plan, topics=""):
"""文案 = 标题 + 话题。**只在这一处定义拼接规则**(两处各配必然分叉)。
def build_caption(plan, topics="", key="title"):
"""文案 = 内容 + 话题。**只在这一处定义拼接规则**(两处各配必然分叉)。
`key` 选内容来源:`title`(标题)/ `body`(正文)/ `title_body`(标题 + 换行 + 正文,
有的抖音版本只有一个输入框时用这个)。
`topics` 支持逗号/换行分隔,不带 `#` 的自动补上。
"""
title = (plan.get("title") or "").strip() if isinstance(plan, dict) else ""
plan = plan if isinstance(plan, dict) else {}
if key == "title_body":
title = chr(10).join(x for x in ((plan.get("title") or "").strip(),
(plan.get("body") or "").strip()) if x)
elif key == "body":
title = (plan.get("body") or "").strip()
else:
title = (plan.get("title") or "").strip()
tags = []
for chunk in re.split(r"[,,\n]", topics or ""):
c = chunk.strip().lstrip("#").strip()
@@ -452,8 +504,13 @@ def build_release_steps():
"label": "⑪ 填标题(自动取这一条计划的标题,会回读校验)",
"params": {"text_source": "release_title", "release_topics": "",
"clear_first": True, "mode": "random", "texts": "", "fixed_text": ""}},
clk("publish", "⑫ 点击「发布」"),
dict(build_mark_step("⑬ 标记发布结果(成功走这里;失败分支再放一个 result=failed)")),
clk("body", "⑫ 点击正文输入框(发布页的文案框)"),
{"id": sid("bodytxt"), "type": "input_text",
"label": "⑬ 填正文(自动取这一条计划的正文,会回读校验)",
"params": {"text_source": "release_body", "release_topics": "",
"clear_first": True, "mode": "random", "texts": "", "fixed_text": ""}},
clk("publish", "⑭ 点击「发布」"),
dict(build_mark_step("⑮ 标记发布结果(成功走这里;失败分支再放一个 result=failed)")),
]
return [
{"id": sid("light"), "type": "screen_on", "label": "⓪ 亮屏",
@@ -685,13 +742,16 @@ def import_titles(text, dry_run=False):
counts["rejected"] += 1
continue
item["seq"] = int(row.seq or 1)
if (row.title or "").strip() == m["title"]:
# 判"配过了"要**标题和正文都比**:同标题补一条正文是正常操作,不能当重复跳过
body_in = m.get("body") or ""
if ((row.title or "").strip() == m["title"]
and (not body_in or (row.body or "").strip() == body_in)):
item["action"] = "skip"
item["error"] = "这条标题已经配过了"
item["error"] = "这条标题/正文已经配过了"
out.append(item)
counts["skipped"] += 1
continue
if (row.title or "").strip():
if (row.title or "").strip() and (row.title or "").strip() != m["title"]:
# 已有别的标题 → 拒收(**不静默覆盖**:文案是发出去就改不了的东西;
# 要换就到计划页改标题,或先删掉这条标题)
item["action"] = "reject"
@@ -703,9 +763,11 @@ def import_titles(text, dry_run=False):
out.append(item)
counts["attached"] += 1
if not dry_run:
row.title = m["title"]
if row.status == ST_PENDING:
row.status = ST_READY
if m["title"]:
row.title = m["title"] # 只有正文的行别把已有标题清掉
if m.get("body"):
row.body = m["body"] # 只有标题的行别把已有正文清掉
_sync_content_status(row)
row.updated_at = _now()
if not dry_run:
db.session.commit()
@@ -716,20 +778,39 @@ def import_titles(text, dry_run=False):
return {"ok": True, "counts": counts, "rows": out, "dry_run": bool(dry_run), "msg": msg}
def set_title(plan_id, title):
"""人工改标题(改完 `pending → ready`)。"""
def _sync_content_status(row):
"""按"有没有内容(标题或正文)"在 `pending`(待内容)与 `ready`(待发布)之间切。
抽出来是因为改标题、改正文、批量导入三处都要用**同一个判据** ——
判据分叉就会出现"界面上有正文却显示待标题、推不出去"这种哑火。
"""
has = bool((row.title or "").strip()) or bool((row.body or "").strip())
if has and row.status == ST_PENDING:
row.status = ST_READY
elif not has and row.status == ST_READY:
row.status = ST_PENDING
def set_title(plan_id, title, body=None):
"""人工改标题 /(可选)正文,改完按"有没有内容"切 pending/ready。
`body=None` = **不动正文**(老调用方语义不变);传空串 = 清掉正文。
"""
title = (title or "").strip()
if len(title) > MAX_TITLE_LEN:
return None, f"标题太长(上限 {MAX_TITLE_LEN} 字)"
if body is not None:
body = (body or "").strip()
if len(body) > MAX_BODY_LEN:
return None, f"正文太长(上限 {MAX_BODY_LEN} 字)"
with _ctx():
row = VideoPlan.query.get(plan_id)
if not row:
return None, "计划不存在"
row.title = title
if title and row.status == ST_PENDING:
row.status = ST_READY
elif not title and row.status == ST_READY:
row.status = ST_PENDING
if body is not None:
row.body = body
_sync_content_status(row)
row.updated_at = _now()
db.session.commit()
return row.to_dict(), ""
+5 -5
View File
@@ -149,7 +149,7 @@
| POST | `/api/video_plan/upload_titles` | D | 上传标题(txt 文本或文件)→ 配到已有计划行 |
| GET | `/api/video_plan/timeline` | D | 发布计划时间线(按日期分组的卡片 + 统计 + 磁盘) |
| GET | `/api/video_plan/stats` | D | 今天要发/已发/待标题/过期未发 + 素材占用与磁盘余量 |
| GET/PUT/DELETE | `/api/video_plan/<id>` | D | 单条计划:查看 / 改标题 / 删除(连素材文件) |
| GET/PUT/DELETE | `/api/video_plan/<id>` | D | 单条计划:查看 / 改标题·正文(`PUT {title,body}`)/ 删除(连素材文件) |
| GET | `/api/video_plan/<id>/video` | D | 预览平台上的素材(文件清理后 → 410) |
| POST | `/api/video_plan/<id>/push` | D | **把素材推到手机**(只推送、不发布;后台线程) |
| POST | `/api/video_plan/push_all` | D | **一键推送**:某天(默认今天)所有待发布/失败的计划 → 各自手机(每台设备一个线程、设备内串行) |
@@ -157,7 +157,7 @@
| POST | `/api/video_plan/<id>/skip\|retry\|resolve` | D | 跳过 / 重试 / 裁决「结果未知」 |
| GET | `/api/video_plan/links` | D | 已发布作品的分享链接(`format=csv` 导出,给铺评论用) |
| GET | `/api/video_plan/tasks` | T | 可编辑的任务列表(全部通用步骤任务 + `is_release` 标记:状态/调度/下次运行 + 完整步骤) |
| POST | `/api/video_plan/tasks` | T | 一键新建标准发布任务(骨架 15 步:亮屏 → 打开抖音 → 校验账号 → 推送 → 抖音点击 → 填标题 → 标记) |
| POST | `/api/video_plan/tasks` | T | 一键新建标准发布任务(骨架 17 步:亮屏 → 打开抖音 → 校验账号 → 推送 → 抖音点击 → 填标题 → 填正文 → 标记) |
| GET/PUT | `/api/video_plan/tasks/<job_id>` | T | **发布任务的就地编辑**(就在「发布计划」页改名字/目标/时间/启停/步骤) |
| POST | `/api/video_plan/tasks/<job_id>/adopt` | T | 给已有任务插上平台两步(推送放最前 / 标记放最后,自己的步骤不动) |
@@ -543,7 +543,7 @@
| 接口 | 请求 | 响应要点 |
|------|------|---------|
| `POST /api/video_plan/upload_video` | multipart `file`(单文件)+ `replace` | `{ok,name,action:add\|skip\|replace,plan,msg}`;解析/配对不通过 → **400 + `error`(人话原因)**。前端**逐文件串行**发(才能给每个文件一条进度与结果) |
| `POST /api/video_plan/upload_titles` | `{text, dry_run}` 或 multipart `file`(txt) | `{ok, counts:{total,attached,skipped,rejected}, rows:[{line,phone,date,seq,title,action,error}], msg}` |
| `POST /api/video_plan/upload_titles` | `{text, dry_run}` 或 multipart `file`(txt) | `{ok, counts:{total,attached,skipped,rejected}, rows:[{line,phone,date,seq,title,body,action,error}], msg}`;**一行里可带正文**:`标题 || 正文 _手机号_日期_编号`(`||` 分隔,正文可省,只切第一处);**两种写法都认**:标题在前(推荐,右锚定,标题里可带下划线)或 `手机号_日期_编号_标题 || 正文`(左锚定,跟视频文件名同序),**编号都可省略**;标题里可以有 `#话题`;空行忽略,`#` 开头的行**只有认不出手机号/日期时**才当注释;拒收条件:手机号不在台账/命中多个、日期不合法、标题为空或 >100 字、该槽位已有标题、没有对应视频 |
| `GET /api/video_plan/timeline` | `?range=today\|week\|all&from=&to=&status=&account_id=&q=&limit=` | `{ok, days:[{date,weekday,total,done,plans:[…]}], stats, disk, today}` |
| `GET /api/video_plan/stats` | — | `{ok, stats, disk, today}`;`stats` 里**三个口径都给**:`today_ready`(今天要发几个号,按发布日期)/ `today_done`(今天完成)/ `published_today`(今天实际发的,按 `published_at`,补发也算今天) |
| `GET /api/video_plan/<id>` · `PUT` · `DELETE` | `PUT {"title"}` | `PUT` 只改标题(改完 `pending→ready`);`DELETE` **先删行再删素材文件** |
@@ -554,9 +554,9 @@
| `POST /api/video_plan/<id>/skip` | `{reason}` | 人工跳过 |
| `POST /api/video_plan/<id>/retry` | — | `failed`→`ready`,`attempts` 清零 |
| `POST /api/video_plan/<id>/resolve` | `{published, note}` | **`unknown` 的人工裁决**:确认真发出去了 → `done`;确认没发 → `failed`(可重试) |
| `GET /api/video_plan/links` | `?from=&to=&device=&phone=&format=csv` | 已发布作品的分享链接;CSV 带 BOM(Excel 打开中文不乱码) |
| `GET /api/video_plan/links` | `?from=&to=&device=&phone=&format=csv` | 已发布作品的分享链接;CSV 带 BOM(Excel 打开中文不乱码)。列:发布日期/设备号/手机号/抖音号/编号/标题/**正文**/分享链接/发布时间(**不再导出账号名称**) |
| `GET /api/video_plan/tasks` | — | `{ok, tasks:[{id,name,enabled,mode,cron,next_run,target,steps,step_count,schedule,retry,is_release,has_push}], release_count, other_count}`:**全部通用步骤任务**(不止发布任务 —— 只列发布任务的话,手写的抖音发布流程会一条都不显示,人会以为"这页没有能改的地方"),界面按 `is_release`(有 `push_release` **或** `mark_release`,含嵌套)分两组。带完整 `steps` 是为**就地编辑**(抓选择器);`step_count` 数的是**含嵌套**的总步数。权限 `T` |
| `POST /api/video_plan/tasks` | `{"name","target":{"mode":"all\|group\|serial","group_name","serial"},"time":"10:00","enabled"}` | 一键新建:骨架 **15 步**(亮屏 → 打开抖音(等首页) → 点「我」→ `if_el(cmp_source=release)` 校验账号 → then: `push_release` → 「+」(预填 `descriptionContains=拍摄`) → 相册/第一个视频/下一步/输入框/发布(**空选择器占位**)→ `input_text(text_source=release_title)` → `mark_release`;else: `notify` 跳过);**走 TaskManager 建**(内存+库一起更新,直接写库调度器不认)。⚠ `target` 分组键名是 **`group_name`**(写成 `group` 会静默解析出 0 台设备);分组/单设备没给具体目标 → 400。权限 `T` |
| `POST /api/video_plan/tasks` | `{"name","target":{"mode":"all\|group\|serial","group_name","serial"},"time":"10:00","enabled"}` | 一键新建:骨架 **17 步**(亮屏 → 打开抖音(等首页) → 点「我」→ `if_el(cmp_source=release)` 校验账号 → then: `push_release` → 「+」(预填 `descriptionContains=拍摄`) → 相册/第一个视频/下一步/标题框/正文框/发布(**空选择器占位**)→ `input_text(text_source=release_title)` → `input_text(text_source=release_body)` → `mark_release`;else: `notify` 跳过);**走 TaskManager 建**(内存+库一起更新,直接写库调度器不认)。⚠ `target` 分组键名是 **`group_name`**(写成 `group` 会静默解析出 0 台设备);分组/单设备没给具体目标 → 400。权限 `T` |
| `GET/PUT /api/video_plan/tasks/<job_id>` | `PUT {name?, time?, target?, steps?, enabled?}` | **发布任务的就地编辑**(「账号 → 发布计划」页里改,不用跳任务页)。只放开这几个字段,**没传的字段原样保留**;`steps` 整块替换但 `params` 里的通用参数(去重有效期、抢占…)**深合并保留**。校验:空名字/坏时间/`steps` 不是数组/什么都不传 → 400,未知任务 → 404。权限 `T`。底层仍是 `TaskManager.update_job()`(内存+库一起改) |
| `POST /api/video_plan/tasks/<job_id>/adopt` | — | 给**已有任务**插上平台那两步:`push_release` 放最前、`mark_release` 放最后(**中间你自己的步骤一步不动**;位置理由见 `core.video_plan.insert_release_steps`)。只对「通用步骤」任务;没有步骤 → 400,已经有这两步 → 409,未知任务 → 404。权限 `T` |
+1 -1
View File
@@ -298,7 +298,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
| `markdown.js` | 轻量 Markdown 渲染(`renderMarkdown`) | 无 CDN 依赖;**先转义再套标记** |
| `list.js` | 统一列表组件:搜索 + 分页 + 排序 | 状态注册表 `_LIST_PAGERS`;`setListPager` **不重置**页码/搜索/排序(避免轮询刷新打断用户) |
| `ledger.js` | 账号台账页(列表 / 单条增删改 / 从表格粘贴导入 + 逐行结果 / 设备维度弹窗)+ `ledgerCell()` 供设备池那一列 | 服务层 `core/ledger.py`;导入默认**先预览再写**(`dry_run`) |
| `release.js` | 发布计划页(时间线按日期分组 / 多选视频**串行**上传 + 逐文件进度与结果 / 标题 txt 上传 / 推送到手机 + **一键推送**(当天全部)、相册校验徽章与裁决 / 链接复制与导出 CSV / **发布任务的就地编辑**) | 服务层 `core/video_plan.py`;上传前把队列**按文件名排序**并让用户确认解析结果(顺序 = "无编号按顺序配"的依据)。就地编辑只放常用字段(名字/目标/时间/启停/逐步参数 + 抓取元素),更复杂的编排走「打开通用编辑器」→ `openTaskModal`;步骤路径里 `then/else/children` 在**步骤的 params 下**(`_rpWalk`),走错这一层会把分支当数组下标用 |
| `release.js` | 发布计划页(时间线按日期分组 / 多选视频**串行**上传 + 逐文件进度与结果 / 标题+正文 txt 上传(`标题 || 正文 _手机号_日期_编号`,**不显示账号名称、只显示抖音号**)/ 推送到手机 + **一键推送**(当天全部)、相册校验徽章与裁决 / 链接复制与导出 CSV / **发布任务的就地编辑**) | 服务层 `core/video_plan.py`;上传前把队列**按文件名排序**并让用户确认解析结果(顺序 = "无编号按顺序配"的依据)。就地编辑只放常用字段(名字/目标/时间/启停/逐步参数 + 抓取元素),更复杂的编排走「打开通用编辑器」→ `openTaskModal`;步骤路径里 `then/else/children` 在**步骤的 params 下**(`_rpWalk`),走错这一层会把分支当数组下标用 |
| `monitor.js` | 监控页:设备表(含**电量列**,按 `battery.tier` 上色)、批量操作、异常汇总、任务概况卡片(含覆盖设备 chip) | 5s 轮询 + 脏检查(签名不变不重渲染) |
| `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例;设备选择卡 `_devCard` 供「抓取元素」「测试此步骤」共用(**名字优先**,型号·地址作副标题) |
| `tasks.js` | 任务 Tab:任务 CRUD / 调度解析 / 运行窗口 + 自定义动作 | |
+3 -2
View File
@@ -16,7 +16,7 @@
| SQLite 回退时的 PRAGMA | `journal_mode=WAL`、`busy_timeout=5000`、`synchronous=NORMAL`(监听器按连接类型守卫,MySQL 连接不会执行) |
| 建表方式 | **唯一真相是模型**:`db.create_all()`(建缺表)+ `_sync_columns()`(补缺列);`SCHEMA_MIGRATIONS` 只作版本账本与数据回填 |
| 库位置 | MySQL:由 `DB_HOST/DB_NAME` 指定;SQLite:`data/users.db` |
| 当前 schema 版本 | `app_meta.schema_version = 10`(迁移清单见 `core/models.py` 的 `SCHEMA_MIGRATIONS`:建表/补列以模型为准,这里只作版本账本) |
| 当前 schema 版本 | `app_meta.schema_version = 11`(迁移清单见 `core/models.py` 的 `SCHEMA_MIGRATIONS`:建表/补列以模型为准,这里只作版本账本) |
**环境与库的绑定**(防混库,见 [DEPLOY.md](DEPLOY.md) §2.2)
@@ -248,7 +248,8 @@
| `device_name` / `nickname` / `douyin_id` / `serial` | String | 账号与设备快照(时间线卡片直接显示,免 join) |
| `release_date` | String(10) index | **`2026-09-12`** 纯日期(等值比较走索引) |
| `seq` / `seq_auto` | Integer / Boolean | 编号从 **1** 起(不用 0 表示"无");`seq_auto`=号是自动分配的 |
| `title` | Text | 文案(标题 txt 配对写入;**空标题不会被发布**) |
| `title` | Text | **标题**(发布页的「标题」框;标题 txt 配对写入) |
| `body` | Text | **正文/文案**(发布页的文案框,可带 `#话题`)—— 标题 txt 一行里用 `||` 分隔:`标题 || 正文 _手机号_日期_编号`;**标题与正文都为空的行不会被发布** |
| `video_file` / `video_name` / `video_size` / `video_sha1` | 各自 | 落盘相对路径 / 原始名 / 大小 / 内容指纹(重复上传判据) |
| `status` | String(16) index | 见下方状态机 |
| `stage` | String(16) | 失败发生在哪一步:`push`/`scan`/`post`/`verify` |
+21 -11
View File
@@ -127,7 +127,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
| 10 | `click_xy` | 点击坐标 | — | `x`(50)、`y`(50) | — | **屏幕百分比**(0-100,会夹取),50/50 = 屏幕中心。无选择器时的兜底,**最脆的方式** |
| 11 | `long_click` | 长按元素 | `selector_type`、`selector_value` | `duration`(1.0)、`wait_timeout`(2) | — | 先等元素出现再 `long_click` |
| 12 | `wait_el` | 等待元素 | `selector_type`、`selector_value` | `timeout`(10) | — | 等元素出现(条件等待,优于固定 `wait`) |
| 13 | `input_text` | 输入文字 | — | `mode`("random")、`texts`("你好\n有趣\n支持")、`fixed_text`("")、`clear_first`(True)、`text_source`("manual")、`release_topics`("") | — | `mode="fixed"` 用 `fixed_text`,否则从 `texts` 按行随机选一条;`text_source=release_title`(或 `release_title_topics`)时**取本机当前那条发布计划的标题**(配 `release_topics` 自动加话题),**并回读输入框校验**(不校验就可能发出一篇空文案);**只负责输入,不负责定位输入框**(要先 click 输入框) |
| 13 | `input_text` | 输入文字 | — | `mode`("random")、`texts`("你好\n有趣\n支持")、`fixed_text`("")、`clear_first`(True)、`text_source`("manual")、`release_topics`("") | — | `mode="fixed"` 用 `fixed_text`,否则从 `texts` 按行随机选一条;`text_source` 还可取**本机当前那条发布计划**的内容:`release_title`(±`_topics`) = 标题、`release_body`(±`_topics`) = 正文、`release_title_body` = 标题+正文(配 `release_topics` 自动加话题),**并回读输入框校验**(不校验就可能发出一篇空文案);**只负责输入,不负责定位输入框**(要先 click 输入框) |
| 14 | `clipboard` | 剪贴板注入 | `text` | `paste`(True) | — | 走**设备端 Agent** 通道(透明 Activity,绕开 Android 10+ 后台写剪贴板限制);老设备上只有独立 ClipInject 时自动兜底(见 `core/clipboard_helper.py`);`paste=true` 时再触发一次粘贴 |
| 15 | `wait` | 等待 | — | `min`(1.0)、`max`(3.0)、`vary_pace`(False) | — | 随机时长;**分片 sleep**(每 ≤0.5s 检查停止/超时),可被抢占打断;勾了 `vary_pace` 再按**本设备节奏**缩放 0.8~1.35 倍(批量跑时设备之间会逐渐错开) |
| 16 | `loop` | 循环块 | — | `loop_mode`("rounds")、`max_iterations`(10)、`loop_duration`(600) | `children` | 见 §4.1 |
@@ -485,7 +485,9 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
```
① 取本机账号(core.ledger.resolve_device —— "本机是哪台"只在那处定义,别写第二套)
② 取计划:本机账号在目标日期上 status ∈ (ready, failed) 且 attempts < 3 的行,按 (日期,编号) 升序
② 取计划(**队列口径**):本机账号名下 `发布日期 <= 目标日期`(过期会补发、未来的不发)、
`status ∈ (ready, failed)`(**done/skipped 永不入队**)、`attempts < 3` 的行,
按 `(发布日期, 编号)` 升序取最早的 —— 即"按顺序发没发过的"
③ **原子占位**:UPDATE … SET status='pushing' WHERE id=? AND status IN ('ready','failed')
← **幂等的唯一判据**(多设备/任务重跑不会把同一条推两遍)
④ adb push → {album_dir}/{计划id}.mp4(默认 /sdcard/DCIM/Camera)→ 校验**大小**(按 ls -l 的大小列比)
@@ -560,15 +562,16 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
这类任务行上会标「**缺平台推送步骤**」并给一个「**插上平台步骤**」按钮
(`POST /api/video_plan/tasks/<id>/adopt`:推送放最前、标记放最后、**你原来的步骤一步不动**)。
还支持**一键新建标准发布任务** ——
骨架 **15 步**已经排好:
骨架 **17 步**已经排好(**标题和正文各一步** —— 对应抖音发布页上那两个框):
```
⓪ 亮屏 ① 打开抖音(等「首页」出来) ② 点击「我」(desc 含"我") ③ 等待 3~5s
④ 条件判断 //*[contains(@text,'抖音号')] 包含 cmp_source=release
├─ then(登的就是要发的号)⑤ 推送发布视频 ⑥ 点击「+」(desc 含"拍摄")
│ ⑦ 相册 ⑧ 第一个视频 ⑨ 下一步 ⑩ 输入框 ⑪ 输入文字(text_source=release_title)
│ ⑫ 点击「发布」 ⑬ 标记发布结果
└─ else(不是这个号 / 今天没有待发计划)⑭ 发通知"本条跳过"
│ ⑫ 点击正文输入框 ⑬ 输入文字(text_source=release_body)
│ ⑭ 点击「发布」 ⑮ 标记发布结果
└─ else(不是这个号 / 今天没有待发计划)⑯ 发通知"本条跳过"
```
> ⚠ **⓪ 亮屏 + ① 等「首页」是踩出来的,别删**:真机实测(2026-09-28,A01)——
@@ -577,17 +580,24 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
> 屏幕亮着时冷启动 5 秒就出底部栏。
建好之后**就在「发布计划」这一块点「编辑」就地改**(名字/目标/每天几点/启停/逐步参数,
选择器旁边就有「抓取元素」),把 5 个空选择器(相册/第一个视频/下一步/输入框/发布)抓一次就能跑 ——
选择器旁边就有「抓取元素」),把 6 个空选择器(相册/第一个视频/下一步/标题框/正文框/发布)抓一次就能跑 ——
要加步骤、循环、自定义动作这些更复杂的编排,才走「打开通用编辑器」(跳任务页)——
**抖音那几步故意不写死选择器**:混淆 id / 结构 xpath 改版就废,
抓一次的成本远低于每次发版都改平台代码。②⑥ 两条预填的是**按描述定位**
(`descriptionContains`,跨版本稳),一般不用改。
**「输入文字」自动用标题**:`text_source=release_title` 会取**本机当前那条计划**的标题
(`push_release` 同一次运行里传递;断档了就取最近一条"已推送到手机"的),
`release_title_topics` 还能自动补话题;**取计划标题时会回读输入框校验**,
不通过就改走剪贴板通道再验一次 —— 抖音的 EditText 有时不吃键盘输入,
不校验的后果是"发出一篇空文案,而每一步都显示成功"。
**「输入文字」自动用计划里的内容**(`text_source`,常量 `RELEASE_TEXT_SOURCES` 一处定义):
| text_source | 取什么 | 用在哪个框 |
|---|---|---|
| `release_title` / `release_title_topics` | 计划的**标题**(`video_plan.title`) | 抖音发布页的「标题」框 |
| `release_body` / `release_body_topics` | 计划的**正文**(`video_plan.body`) | 抖音发布页的文案/正文框 |
| `release_title_body` | 标题 + 换行 + 正文 | 只有一个输入框的抖音版本 |
带 `_topics` 的会再把 `release_topics`(逗号分隔)拼成 `#话题`。
内容由 `push_release` 在同一次运行里传递(断档了就取最近一条"已推送到手机"的);
**取计划内容时会回读输入框校验**,不通过就改走剪贴板通道再验一次 ——
抖音的 EditText 有时不吃键盘输入,不校验的后果是"发出一篇空文案,而每一步都显示成功"。
**抓分享链接**(`mark_release` 的 `capture_link=True`,或计划页人工点「标记已发布」时):
进「我 → 作品」第一条 → 分享 → 复制链接 → 读剪贴板 → 正则抠出 `v.douyin.com` 链接。
+4 -2
View File
@@ -444,11 +444,13 @@ var _stepEditor={
const fromPlan=(tsrc!=='manual');
// 取值来源:默认手填(行为与以前一致);也可以直接吃「发布计划」里那一条的标题
h+='<div class="form-group"><label>取值来源</label><select class="form-control" data-key="text_source" onchange="_stepEditor._inputSourceChanged(this)">'+
[['manual','手填 / 随机候选(默认)'],['release_title','发布计划这一条的标题'],['release_title_topics','发布计划标题 + 话题']]
[['manual','手填 / 随机候选(默认)'],['release_title','发布计划这一条的标题'],
['release_title_topics','发布计划标题 + 话题'],['release_body','发布计划这一条的正文'],
['release_body_topics','发布计划正文 + 话题'],['release_title_body','标题 + 正文(一个框时用)']]
.map(function(o){return '<option value="'+o[0]+'"'+(tsrc===o[0]?' selected':'')+'>'+o[1]+'</option>';}).join('')+
'</select><div class="help">选"发布计划"时,标题由前面的「推送发布视频」那一步取出来(同一次运行里传递;没取到会跳过并告警)—— 你就不用把同一句文案再抄一遍了</div></div>';
if(fromPlan){
h+='<div class="form-group" data-key-wrap="release_topics" style="display:'+(tsrc==='release_title_topics'?'block':'none')+'"><label>话题(逗号分隔,不带 # 会自动补)</label><input type="text" class="form-control" data-key="release_topics" value="'+esc(p.release_topics||'')+'" placeholder="如 苏州,日常"></div>';
h+='<div class="form-group" data-key-wrap="release_topics" style="display:'+(/topics$/.test(tsrc)?'block':'none')+'"><label>话题(逗号分隔,不带 # 会自动补)</label><input type="text" class="form-control" data-key="release_topics" value="'+esc(p.release_topics||'')+'" placeholder="如 苏州,日常"></div>';
h+='<div class="help" style="color:#f59e0b">取计划标题时<b>会回读输入框校验</b>:抖音的输入框有时不吃键盘输入,不校验就可能发出一篇空文案;校验不过会自动改走剪贴板粘贴再验一次</div>';
}
// 手填那两块:**只隐藏不删**(_syncParams 按 DOM 存在与否收集,删了会把用户填的值抹掉)
+11 -14
View File
@@ -20,7 +20,7 @@ function loadLedger() {
apiGet('/api/ledger?limit=2000').then(function (r) {
var tb = document.getElementById('tb-ledger');
if (!r || !r.ok) {
if (tb) tb.innerHTML = '<tr><td colspan="10" class="empty">读取失败:' + esc((r && r.error) || '') + '</td></tr>';
if (tb) tb.innerHTML = '<tr><td colspan="9" class="empty">读取失败:' + esc((r && r.error) || '') + '</td></tr>';
return;
}
_ledgerAll = r.accounts || [];
@@ -60,13 +60,13 @@ function renderLedger() {
setListPager('ledger', {
data: function () { return rows; },
filter: function (a, kw) {
return ((a.douyin_id || '') + (a.phone || '') + (a.nickname || '')
return ((a.douyin_id || '') + (a.phone || '')
+ (a.device_name || '') + (a.note || '')).toLowerCase().indexOf(kw) >= 0;
},
sortValue: function (a, k) { return a[k]; },
render: renderLedgerRow,
tbody: 'tb-ledger', pager: 'pager-ledger', pageSize: 20,
empty: '<tr><td colspan="10" class="empty">还没有账号 —— 点「粘贴导入」把你那张表贴进来</td></tr>'
empty: '<tr><td colspan="9" class="empty">还没有账号 —— 点「粘贴导入」把你那张表贴进来</td></tr>'
});
applyListPager('ledger');
initListSearch('ledger', 'search-ledger');
@@ -86,7 +86,6 @@ function _clip(s, n) {
function renderLedgerRow(a) {
return '<tr>' +
'<td><strong>' + esc(a.device_name || '-') + '</strong></td>' +
'<td>' + esc(a.nickname || '-') + '</td>' +
'<td style="font-family:var(--mono)">' + esc(a.douyin_id || '-') + '</td>' +
'<td style="font-family:var(--mono)">' + esc(a.phone || '-') + '</td>' +
'<td>' + esc(a.registered_at || '-') + '</td>' +
@@ -117,10 +116,9 @@ function _ledgerField(label, key, val, ph) {
function openLedgerModal(id) {
var a = id ? _ledgerById(id) : null;
document.getElementById('modal-title').textContent = a ? ('编辑账号:' + (a.nickname || a.douyin_id)) : '新增账号';
document.getElementById('modal-title').textContent = a ? ('编辑账号:' + (a.douyin_id || a.phone)) : '新增账号';
var h = '<div class="form-row">' +
_ledgerField('设备号', 'device_name', a && a.device_name, '如 A01(要和设备池里的设备名一致)') +
_ledgerField('账号名称', 'nickname', a && a.nickname, '如 AA建材王总') + '</div>' +
'<div class="form-row">' +
_ledgerField('抖音号', 'douyin_id', a && a.douyin_id, '纯号,如 35377983067(不带「抖音号:」前缀)') +
_ledgerField('手机号', 'phone', a && a.phone, '') + '</div>' +
@@ -167,7 +165,7 @@ function saveLedger(id) {
function deleteLedger(id) {
var a = _ledgerById(id);
var who = a ? ((a.device_name || '') + ' / ' + (a.nickname || '') + ' / ' + (a.douyin_id || '')) : id;
var who = a ? ((a.device_name || '') + ' / ' + (a.douyin_id || '')) : id;
if (!confirm('删掉这条账号?\n\n' + who + '\n\n删掉之后:任务从台账取号就取不到它了。')) return;
apiDelete('/api/ledger/' + id).then(function (r) {
if (r && r.ok) { showToast(r.msg || '已删除', 'success'); loadLedger(); loadLedgerCounts(); }
@@ -182,7 +180,7 @@ function openLedgerImport() {
document.getElementById('modal-body').innerHTML =
'<div class="help" style="margin-bottom:8px">' +
'把 Excel 里的表格<b>连表头一起</b>复制,粘到下面。首行是列名,顺序随意、缺列留空。<br>' +
'认得的列名:<code>设备号 / 手机号 / 账号名称 / 抖音号 / 注册时间 / 卡在机内 / 可发视频 / 简介 / 备注</code>' +
'认得的列名:<code>设备号 / 手机号 / 抖音号 / 注册时间 / 卡在机内 / 可发视频 / 简介 / 备注</code>(<b>账号名称列会被忽略</b>)' +
'(认不出的列会被忽略)。<b>没有表头</b>时按上面这个顺序解析。<br>' +
'「卡在机内 / 可发视频」认 <code>是/有/1</code> 与 <code>否/空</code>。' +
'</div>' +
@@ -197,7 +195,7 @@ function openLedgerImport() {
'<option value="overwrite">覆盖(整行替换,空单元格会覆盖掉原值)</option></select></div>' +
'</div>' +
'<div class="form-group"><textarea id="ledger-import-text" class="form-control" rows="10" ' +
'style="font-family:var(--mono);font-size:12px" placeholder="设备号&#9;手机号&#9;账号名称&#9;抖音号&#9;注册时间&#9;卡在机内&#9;可发视频&#9;简介&#9;备注&#10;A01&#9;13800000000&#9;示例账号&#9;35000000001&#9;2026/9/24&#9;否&#9;是&#9;&#9;"></textarea></div>' +
'style="font-family:var(--mono);font-size:12px" placeholder="设备号&#9;手机号&#9;抖音号&#9;注册时间&#9;卡在机内&#9;可发视频&#9;简介&#9;备注&#10;A01&#9;13800000000&#9;35000000001&#9;2026/9/24&#9;否&#9;是&#9;&#9;"></textarea></div>' +
'<div id="ledger-import-result"></div>';
document.getElementById('modal-footer').innerHTML =
'<button class="btn" onclick="closeModal()">取消</button>' +
@@ -243,7 +241,6 @@ function renderImportResult(r) {
'<td><span class="label ' + a[0] + '">' + a[1] + '</span></td>' +
'<td>' + esc(x.device_name || '') + '</td>' +
'<td style="font-family:var(--mono)">' + esc(x.douyin_id || '') + '</td>' +
'<td>' + esc(x.nickname || '') + '</td>' +
'<td style="color:#f59e0b">' + esc(msg) + '</td></tr>';
}).join('');
var errs = (r.errors || []).map(function (e) {
@@ -252,7 +249,7 @@ function renderImportResult(r) {
box.innerHTML = head +
_importErrorsNote(errs) +
'<div style="max-height:320px;overflow:auto"><table class="table table-sm">' +
'<thead><tr><th>行</th><th>动作</th><th>设备号</th><th>抖音号</th><th>账号名称</th><th>说明</th></tr></thead>' +
'<thead><tr><th>行</th><th>动作</th><th>设备号</th><th>抖音号</th><th>说明</th></tr></thead>' +
'<tbody>' + (rows || '<tr><td colspan="6" class="empty">没有可导入的行</td></tr>') + '</tbody></table></div>' +
(r.dry_run ? '<div class="help">确认没问题后点「直接导入」。</div>' : '');
}
@@ -284,7 +281,7 @@ function showDeviceAccounts(name) {
}
document.getElementById('modal-title').textContent = name + ' 上的账号(' + (g.count || 0) + ' 个)';
var rows = (g.accounts || []).map(function (a) {
return '<tr><td>' + esc(a.nickname || '-') + '</td>' +
return '<tr>' +
'<td style="font-family:var(--mono)">' + esc(a.douyin_id || '-') + '</td>' +
'<td style="font-family:var(--mono)">' + esc(a.phone || '-') + '</td>' +
'<td>' + _yn(a.sim_in_device) + '</td><td>' + _yn(a.can_post_video) + '</td>' +
@@ -292,8 +289,8 @@ function showDeviceAccounts(name) {
}).join('');
document.getElementById('modal-body').innerHTML =
'<div style="max-height:60vh;overflow:auto"><table class="table table-sm">' +
'<thead><tr><th>账号名称</th><th>抖音号</th><th>手机号</th><th>卡在机内</th><th>可发视频</th><th>注册时间</th><th>备注</th></tr></thead>' +
'<tbody>' + (rows || '<tr><td colspan="7" class="empty">这台设备还没有登记账号</td></tr>') + '</tbody></table></div>' +
'<thead><tr><th>抖音号</th><th>手机号</th><th>卡在机内</th><th>可发视频</th><th>注册时间</th><th>备注</th></tr></thead>' +
'<tbody>' + (rows || '<tr><td colspan="6" class="empty">这台设备还没有登记账号</td></tr>') + '</tbody></table></div>' +
'<div class="help">在「账号」页可以增删改;设备端 Agent 的「身份大字页」显示的就是这些。</div>';
document.getElementById('modal-footer').innerHTML = '<button class="btn" onclick="closeModal()">关闭</button>';
showModal();
+41 -18
View File
@@ -97,14 +97,16 @@ function renderReleaseTimeline() {
}
function rpCard(p) {
var acc = '<b>' + esc(p.device_name || '?') + '</b> ' + esc(p.nickname || '')
+ ' <span class="text-muted" style="font-family:var(--mono)">'
var acc = '<b>' + esc(p.device_name || '?') + '</b> '
+ '<span class="text-muted" style="font-family:var(--mono)">'
+ esc(p.douyin_id || p.phone) + '</span>';
var video = p.video_name
? '<span title="' + esc(p.video_name) + '">' + esc(p.video_name.slice(0, 40)) + '</span>'
+ (p.video_deleted_at ? ' <span class="label label-default">素材已清理</span>' : '')
: '<span class="label label-default">素材已清理</span>';
var title = p.title ? esc(p.title.slice(0, 30)) : '<span style="color:#f59e0b">⚠ 缺标题</span>';
var title = p.title ? esc(p.title.slice(0, 30))
: (p.body ? '' : '<span style="color:#f59e0b">⚠ 缺标题/正文</span>');
var body = p.body ? esc(p.body.slice(0, 60)) : '<span class="text-muted">(无正文)</span>';
var link = p.share_url
? '<a href="' + esc(p.share_url) + '" target="_blank" style="font-family:var(--mono)">'
+ esc(p.share_url.replace('https://v.douyin.com/', '…/')) + '</a>'
@@ -126,7 +128,7 @@ function rpCard(p) {
if (p.status === 'failed' || p.status === 'unknown') {
ops.push('<button class="btn btn-xs" onclick="rpRetry(\'' + p.id + '\')" data-perm="devices">改回待发</button>');
}
ops.push('<button class="btn btn-xs" onclick="rpEditTitle(\'' + p.id + '\')" data-perm="devices">改标题</button>');
ops.push('<button class="btn btn-xs" onclick="rpEditTitle(\'' + p.id + '\')" data-perm="devices">改标题/正文</button>');
if (p.status !== 'done' && p.status !== 'skipped') {
ops.push('<button class="btn btn-xs" onclick="rpSkip(\'' + p.id + '\')" data-perm="devices">跳过</button>');
}
@@ -153,8 +155,8 @@ function rpCard(p) {
+ (p.seq_auto ? '(自动)' : '') + '</span> ' + rpStatusLabel(p.status)
+ (p.attempts ? ' <span class="text-muted">已试 ' + p.attempts + ' 次</span>' : '') + pushTag + '</div>'
+ '<div style="white-space:nowrap">' + ops.join(' ') + '</div></div>'
+ '<div style="margin-top:4px">素材:' + video + ' | 标题:' + title
+ (link ? ' | ' + link : '') + '</div>'
+ '<div style="margin-top:4px">素材:' + video + ' | 标题:' + title + '</div>'
+ '<div style="margin-top:2px">正文:' + body + (link ? ' | ' + link : '') + '</div>'
+ (p.published_at ? '<div class="text-muted" style="font-size:12px">发布于 ' + esc(p.published_at) + '</div>' : '')
+ warn + '</div>';
}
@@ -163,24 +165,26 @@ function renderReleaseTable() {
setListPager('release', {
data: function () { return _rpRows; },
filter: function (p, kw) {
return ((p.device_name || '') + (p.nickname || '') + (p.phone || '') + (p.douyin_id || '')
+ (p.title || '') + (p.video_name || '')).toLowerCase().indexOf(kw) >= 0;
return ((p.device_name || '') + (p.phone || '') + (p.douyin_id || '')
+ (p.title || '') + (p.body || '') + (p.video_name || '')).toLowerCase().indexOf(kw) >= 0;
},
sortValue: function (p, k) { return p[k]; },
render: function (p) {
return '<tr><td>' + esc(p.release_date) + '</td><td>' + esc(p.device_name || '-') + '</td>'
+ '<td>' + esc(p.nickname || '-') + '/' + esc(p.douyin_id || p.phone) + '</td>'
+ '<td>' + esc(p.douyin_id || p.phone || '-') + '</td>'
+ '<td>' + p.seq + (p.seq_auto ? '*' : '') + '</td>'
+ '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'
+ esc(p.video_name) + '">' + esc(p.video_name || '-') + '</td>'
+ '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'
+ esc(p.title) + '">' + esc(p.title || '-') + '</td>'
+ '<td style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="'
+ esc(p.body) + '">' + esc(p.body || '-') + '</td>'
+ '<td>' + rpStatusLabel(p.status) + '</td>'
+ '<td>' + (p.share_url ? '<button class="btn btn-xs" onclick="rpCopyLink(\'' + esc(p.share_url) + '\')">复制链接</button>' : '-') + '</td>'
+ '<td><button class="btn btn-xs" onclick="rpEditTitle(\'' + p.id + '\')" data-perm="devices">改标题</button></td></tr>';
+ '<td><button class="btn btn-xs" onclick="rpEditTitle(\'' + p.id + '\')" data-perm="devices">改标题/正文</button></td></tr>';
},
tbody: 'tb-release', pager: 'pager-release', pageSize: 20,
empty: '<tr><td colspan="9" class="empty">这个范围里没有计划</td></tr>'
empty: '<tr><td colspan="10" class="empty">这个范围里没有计划</td></tr>'
});
applyListPager('release');
initListSearch('release', 'search-release');
@@ -371,7 +375,7 @@ function rpShowTitleConfirm(fname, text) {
document.getElementById('modal-box').classList.add('wide-modal');
document.getElementById('modal-body').innerHTML =
'<div class="help" style="margin-bottom:6px">共 ' + lines.length + ' 行有效标题。'
+ '每行 <code>标题内容_手机号_日期_编号</code>;没写编号的按顺序配到该账号当天还没标题的视频上。</div>'
+ '每行 <code>标题内容_手机号_日期_编号</code> 或 <code>手机号_日期_编号_标题</code>(都认,编号可省,标题里可以有 <code>#话题</code>);<br>没写编号的按顺序配到该账号当天还没标题的视频上。</div>'
+ '<div style="max-height:50vh;overflow:auto;font-family:var(--mono);font-size:12px;white-space:pre-wrap">'
+ esc(lines.slice(0, 200).join('\n')) + (lines.length > 200 ? '\n…' : '') + '</div>'
+ '<div id="rp-title-result"></div>';
@@ -477,14 +481,30 @@ function rpDelete(id) {
});
}
// 改标题 + 正文(发布页两个框分别填;都按"有没有内容"决定这条是不是「待发布」)
function rpEditTitle(id) {
var p = null;
for (var i = 0; i < _rpRows.length; i++) if (_rpRows[i].id === id) p = _rpRows[i];
var cur = p ? (p.title || '') : '';
var t = prompt('标题(发布文案):', cur);
if (t === null) return;
apiPut('/api/video_plan/' + id, { title: t }).then(function (r) {
if (r && r.ok) { showToast(r.msg, 'success'); loadReleasePlan(); } else showToast((r && r.error) || '失败', 'error');
document.getElementById('modal-title').textContent = '改标题 / 正文';
document.getElementById('modal-body').innerHTML =
'<div class="help">抖音发布页有两个框:<b>标题</b>(短,用于搜索展示)和<b>正文</b>(文案,可带 #话题)。'
+ '两个框在任务里各由一个「输入文字」步骤填(取值来源分别选「计划的标题」/「计划的正文」)。</div>'
+ '<div class="form-group"><label>标题</label>'
+ '<input class="form-control" id="rp-ed-t" value="' + esc(p ? (p.title || '') : '') + '" maxlength="100"></div>'
+ '<div class="form-group"><label>正文(可留空)</label>'
+ '<textarea class="form-control" id="rp-ed-b" rows="4">' + esc(p ? (p.body || '') : '') + '</textarea></div>';
document.getElementById('modal-footer').innerHTML =
'<button class="btn" onclick="closeModal()">取消</button>'
+ '<button class="btn btn-primary" onclick="rpSaveTitleBody(\'' + id + '\')">保存</button>';
showModal();
}
function rpSaveTitleBody(id) {
var t = (document.getElementById('rp-ed-t') || {}).value || '';
var b = (document.getElementById('rp-ed-b') || {}).value || '';
apiPut('/api/video_plan/' + id, { title: t, body: b }).then(function (r) {
if (r && r.ok) { closeModal(); showToast(r.msg || '已改', 'success'); loadReleasePlan(); }
else showToast((r && r.error) || '失败', 'error');
});
}
@@ -672,7 +692,10 @@ var _RP_FIELDS = {
wait: [['min', '最短(秒)', 'num'], ['max', '最长(秒)', 'num']],
input_text: [['text_source', '文案从哪来', 'sel', [['manual', '手填(下面那栏)'],
['release_title', '这条发布计划的标题'],
['release_title_topics', '计划的标题 + 话题']]],
['release_title_topics', '计划的标题 + 话题'],
['release_body', '这条发布计划的正文'],
['release_body_topics', '计划的正文 + 话题'],
['release_title_body', '标题 + 正文(一个框时用)']]],
['release_topics', '话题(逗号分隔)', 'text'],
['fixed_text', '手填的文案(上面的来源选手填时才用)', 'area']],
push_release: [['date_source', '发哪天的计划', 'sel', [['today', '今天'], ['fixed', '指定日期']]],
+19 -7
View File
@@ -72,6 +72,16 @@ def _norm_legacy_xpath(sel_val):
# ================== 步骤类型定义(前端操作库 + 后端执行共用)==================
# 「输入文字」的取值来源:plan 的字段名 → build_caption 的 key。
# 集中一处定义,免得下面判据分叉(取了标题却当正文填)——加新来源只改这里。
RELEASE_TEXT_SOURCES = {
"release_title": "title",
"release_title_topics": "title",
"release_body": "body",
"release_body_topics": "body",
"release_title_body": "title_body",
}
STEP_TYPES = [
{"type": "open_app", "label": "打开App", "icon": "▶",
"params": {"package": "", "wait_home": False, "home_feature": ""}},
@@ -1376,13 +1386,15 @@ class GenericStepsWorker(BaseWorker):
· `manual`(默认)= 用上面的 mode(行为与以前完全一致)
· `release_title` = **本机当前那条发布计划的标题**("推送发布视频"推的那条;
本机没有就取最近一条已推送的)· 配 `release_topics` 还能自动加话题
· `release_body` = 那条计划的**正文**(同一套来源与回读校验)
· `release_title_body` = 标题 + 换行 + 正文(抖音只有一个输入框时用这个)
选发布计划来源时**会回读校验**(读输入框的 text):不校验的后果是
"抖音不吃 send_keys → 发出一篇空文案,而每一步都显示成功";
校验不过就自动改走剪贴板通道粘贴(设备端 Agent)再验一次。
"""
src = str(params.get("text_source") or "manual").strip()
text = ""
if src in ("release_title", "release_title_topics"):
if src in RELEASE_TEXT_SOURCES:
from core import video_plan as vp
plan = getattr(self, "_last_release", None)
if not plan:
@@ -1390,14 +1402,14 @@ class GenericStepsWorker(BaseWorker):
plan = vp.last_pushed_for_device(phones) if phones else None
if plan:
text, _err = vp.build_caption(
plan, params.get("release_topics") or ""
if src == "release_title_topics" else "")
plan, params.get("release_topics") or "",
key=RELEASE_TEXT_SOURCES[src])
self._last_release = self._last_release or plan
_log.info(f"[{self.serial}] 输入文字取计划标题:"
f"{plan.get('nickname') or plan.get('phone')} "
_log.info(f"[{self.serial}] 输入文字取计划{RELEASE_TEXT_SOURCES[src]}:"
f"{plan.get('douyin_id') or plan.get('phone')} "
f"{plan.get('release_date')} #{plan.get('seq')}")
if not text:
_log.warning(f"[{self.serial}] 输入文字:本机没有可用的发布计划标题 → 跳过"
_log.warning(f"[{self.serial}] 输入文字:本机没有可用的发布计划内容 → 跳过"
f"(前面要先有「推送发布视频」步骤)")
return None
if not text:
@@ -1433,7 +1445,7 @@ class GenericStepsWorker(BaseWorker):
_log.warning(f"[{self.serial}] input_text 失败: {e2}")
# 取计划标题时**必须回读校验**:抖音的 EditText 有时不吃 send_keys,
# 不校验就可能发出一篇空文案而每一步都显示成功
if src in ("release_title", "release_title_topics"):
if src in RELEASE_TEXT_SOURCES:
return self._verify_input(d, text, clear_first)
return None
+4 -5
View File
@@ -608,7 +608,6 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<table class="table">
<thead><tr>
<th data-sort="device_name" onclick="_lpSort('ledger','device_name')">设备号</th>
<th data-sort="nickname" onclick="_lpSort('ledger','nickname')">账号名称</th>
<th data-sort="douyin_id" onclick="_lpSort('ledger','douyin_id')">抖音号</th>
<th>手机号</th>
<th>注册时间</th>
@@ -638,7 +637,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<button class="btn btn-primary" onclick="document.getElementById('rp-video-input').click()"
data-perm="devices">上传视频</button>
<button class="btn" onclick="document.getElementById('rp-title-input').click()"
data-perm="devices">上传标题</button>
data-perm="devices">上传标题/正文</button>
<button class="btn btn-primary" onclick="rpPushAll()" data-perm="devices"
title="把今天所有「待发布/失败」的计划按设备推到手机上(每台设备串行推)">一键推送(今天)</button>
<button class="btn" onclick="rpExportLinks()">导出链接 CSV</button>
@@ -682,8 +681,8 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<summary style="cursor:pointer;color:var(--text-light)">明细表(与时间线同一份数据)</summary>
<table class="table table-sm" style="margin-top:8px">
<thead><tr>
<th>发布日期</th><th>设备号</th><th>账号</th><th>编号</th><th>视频文件</th>
<th>标题</th><th>状态</th><th>分享链接</th><th>操作</th>
<th>发布日期</th><th>设备号</th><th>抖音号</th><th>编号</th><th>视频文件</th>
<th>标题</th><th>正文</th><th>状态</th><th>分享链接</th><th>操作</th>
</tr></thead>
<tbody id="tb-release"></tbody>
</table>
@@ -691,7 +690,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<div class="help">
视频文件名规则 <code>手机号_日期_编号</code>(编号可省略,日期如 <code>20260912</code> 就是发布日期);
标题 txt 每行 <code>标题内容_手机号_日期_编号</code>(标题里可以有下划线,<code>#</code> 开头的行忽略)。
标题 txt 每行 <code>标题 || 正文 _手机号_日期_编号</code>(正文可省;也能写成 <code>手机号_日期_编号_标题 || 正文</code>)(<b>两种写法都认</b>,编号都可省略,标题里可以有下划线、可以有 <code>#话题</code>;<code>#</code> 开头的行<b>只要认得出手机号/日期就当标题</b>,认不出才当注释忽略)。
<b>手机号必须在「账号台账」里</b>,否则拒收。<br>
上传后自动按 (手机号, 日期, 编号) 配对:有编号按编号精确对,没编号按顺序配 ——
<b>没传标题的视频不会被推送</b>(先在计划页改标题或补传标题)。<br>
+10 -9
View File
@@ -42,7 +42,8 @@ def api_vp_upload_video():
def api_vp_upload_titles():
"""上传标题:`{text, dry_run}`;也接受 multipart 的 txt 文件(字段名 file)。
每行 `标题内容_手机号_日期_编号`(编号可省略、标题里可以有下划线);`#` 开头的行忽略。
每行 `标题内容_手机号_日期_编号` 或 `手机号_日期_编号_标题`(都认;编号可省略、标题里可以有下划线);
`#` 开头的行**只有认不出手机号/日期时才当注释**(`#话题` 开头的标题照收)。
"""
if request.files.get("file"):
raw = request.files["file"].stream.read()
@@ -104,11 +105,11 @@ def api_vp_one(pid):
return jsonify({"ok": False, "error": "计划不存在"}), 404
return jsonify({"ok": True, "msg": "已删除(素材文件一并删除)"})
data = request.json or {}
if "title" in data:
row, err = vp.set_title(pid, data.get("title") or "")
if "title" in data or "body" in data:
row, err = vp.set_title(pid, data.get("title") or "", body=data.get("body"))
if err:
return jsonify({"ok": False, "error": err}), 400 if "太长" in err else 404
return jsonify({"ok": True, "msg": "标题已改", "plan": row})
return jsonify({"ok": True, "msg": "标题/正文已改", "plan": row})
return jsonify({"ok": False, "error": "没有要改的字段"}), 400
@@ -492,12 +493,12 @@ def api_vp_links():
if (request.args.get("format") or "") == "csv":
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["发布日期", "设备号", "手机号", "账号名称", "抖音号",
"编号", "标题", "分享链接", "发布时间"])
w.writerow(["发布日期", "设备号", "手机号", "抖音号",
"编号", "标题", "正文", "分享链接", "发布时间"])
for r in rows:
w.writerow([r["release_date"], r["device_name"], r["phone"], r["nickname"],
r["douyin_id"], r["seq"], r["title"], r["share_url"],
r["published_at"]])
w.writerow([r["release_date"], r["device_name"], r["phone"],
r["douyin_id"], r["seq"], r["title"], r.get("body", ""),
r["share_url"], r["published_at"]])
data = ("" + buf.getvalue()).encode("utf-8") # BOM:Excel 打开中文不乱码
return send_file(io.BytesIO(data), mimetype="text/csv", as_attachment=True,
download_name=f"video_links_{vp._today()}.csv")