diff --git a/core/video_plan.py b/core/video_plan.py index 19c4e06..2bd7979 100644 --- a/core/video_plan.py +++ b/core/video_plan.py @@ -162,32 +162,56 @@ 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, "缺标题内容(行首就是手机号)" + return None, "缺标题内容(只有手机号/日期/编号,没有标题文字)" if len(title) > MAX_TITLE_LEN: return None, f"标题太长({len(title)} 字 > {MAX_TITLE_LEN} 字上限),请改短或拆成两条" return {"title": title, "phone": phone, "date": date, @@ -195,15 +219,22 @@ def parse_title_line(line): 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 diff --git a/doc/API.md b/doc/API.md index 00f72a6..c58c429 100644 --- a/doc/API.md +++ b/doc/API.md @@ -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,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/` · `PUT` · `DELETE` | `PUT {"title"}` | `PUT` 只改标题(改完 `pending→ready`);`DELETE` **先删行再删素材文件** | diff --git a/doc/TASK_DEV.md b/doc/TASK_DEV.md index ec46807..7838b51 100644 --- a/doc/TASK_DEV.md +++ b/doc/TASK_DEV.md @@ -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 的大小列比) diff --git a/static/admin/release.js b/static/admin/release.js index b08f891..0974d2b 100644 --- a/static/admin/release.js +++ b/static/admin/release.js @@ -371,7 +371,7 @@ function rpShowTitleConfirm(fname, text) { document.getElementById('modal-box').classList.add('wide-modal'); document.getElementById('modal-body').innerHTML = '
共 ' + lines.length + ' 行有效标题。' - + '每行 标题内容_手机号_日期_编号;没写编号的按顺序配到该账号当天还没标题的视频上。
' + + '每行 标题内容_手机号_日期_编号 或 手机号_日期_编号_标题(都认,编号可省,标题里可以有 #话题);
没写编号的按顺序配到该账号当天还没标题的视频上。' + '
' + esc(lines.slice(0, 200).join('\n')) + (lines.length > 200 ? '\n…' : '') + '
' + '
'; diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 11a13dd..8743e2d 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -691,7 +691,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
视频文件名规则 手机号_日期_编号(编号可省略,日期如 20260912 就是发布日期); - 标题 txt 每行 标题内容_手机号_日期_编号(标题里可以有下划线,# 开头的行忽略)。 + 标题 txt 每行 标题内容_手机号_日期_编号 或 手机号_日期_编号_标题(两种写法都认,编号都可省略,标题里可以有下划线、可以有 #话题;# 开头的行只要认得出手机号/日期就当标题,认不出才当注释忽略)。 手机号必须在「账号台账」里,否则拒收。
上传后自动按 (手机号, 日期, 编号) 配对:有编号按编号精确对,没编号按顺序配 —— 没传标题的视频不会被推送(先在计划页改标题或补传标题)。
diff --git a/web/video_plan_api.py b/web/video_plan_api.py index 9d1f019..9ff2275 100644 --- a/web/video_plan_api.py +++ b/web/video_plan_api.py @@ -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()