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