feat(发布计划): 标题行两种写法都认 + 行首 # 不再无条件当注释
一、标题行解析(core/video_plan.parse_title_line) - 原来只认「标题内容_手机号_日期_编号」(右锚定)。现在**两种都认**: ① 标题内容_手机号_日期_编号 (推荐,右锚定 —— 标题里带下划线也不会错配) ② 手机号_日期_编号_标题 (左锚定,跟视频文件名同序 = "文件名去掉扩展名 + 标题") 两种里都**先试带编号的**,保证同一行永远只有一种解释;编号都可省略。 - 新拒收一条:`手机号_日期_1`(只有编号、没标题)**直接拒** —— 不能把它当标题"1"(静默生成一条标题是"1"的文案,比拒收危险得多); 标题真是纯数字的用写法①。 二、`#` 开头的行(parse_titles_text) - 原来是"以 `#` 开头就整行忽略" → **`#中秋快乐_...` 这种正常标题会被静默丢掉**。 - 改成**先按标题行解析,解析得出就当标题;解析不出且以 `#` 开头才算注释**。 `# 这是注释` 照样忽略,`#话题` 开头的标题照收(`#` 保留在标题里)。 三、其它 - 文案同步:上传标题面板/帮助文案、doc/API.md 的 upload_titles 语义(两种写法、`#` 规则、拒收条件) - 测试:新增 8 条(两种写法 × 带/不带编号 × 标题含下划线与 #话题、只有编号要拒收、整段注释与报错)
This commit is contained in:
+39
-8
@@ -162,32 +162,56 @@ def parse_video_name(filename):
|
|||||||
|
|
||||||
|
|
||||||
def parse_title_line(line):
|
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()
|
t = (line or "").strip()
|
||||||
if not t:
|
if not t:
|
||||||
return None, "空行"
|
return None, "空行"
|
||||||
parts = t.split("_")
|
parts = t.split("_")
|
||||||
phone = date = seq = None
|
phone = date = seq = None
|
||||||
|
title_parts = None
|
||||||
|
# ① 右锚定:先试带编号的,再试不带编号的
|
||||||
if len(parts) >= 4 and _SEQ_RE.match(parts[-1]) \
|
if len(parts) >= 4 and _SEQ_RE.match(parts[-1]) \
|
||||||
and _DATE8_RE.match(parts[-2]) and _PHONE_RE.match(parts[-3]):
|
and _DATE8_RE.match(parts[-2]) and _PHONE_RE.match(parts[-3]):
|
||||||
seq = int(parts[-1])
|
seq = int(parts[-1])
|
||||||
phone, date8, title_parts = parts[-3], parts[-2], parts[:-3]
|
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]):
|
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]
|
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:
|
else:
|
||||||
return None, "格式不对,应为 标题内容_手机号_日期_编号(编号可省略)"
|
return None, ("格式不对,应为「标题内容_手机号_日期_编号」"
|
||||||
|
"或「手机号_日期_编号_标题」(编号都可省略)")
|
||||||
d = parse_date8(date8)
|
d = parse_date8(date8)
|
||||||
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()
|
title = "_".join(title_parts).strip()
|
||||||
if not title:
|
if not title:
|
||||||
return None, "缺标题内容(行首就是手机号)"
|
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,
|
return {"title": title, "phone": phone, "date": date,
|
||||||
@@ -195,15 +219,22 @@ def parse_title_line(line):
|
|||||||
|
|
||||||
|
|
||||||
def parse_titles_text(text):
|
def parse_titles_text(text):
|
||||||
"""标题 txt 全文 → (rows, errors)。`#` 开头的行与空行自动跳过(不算错)。"""
|
"""标题 txt 全文 → (rows, errors)。空行跳过;`#` 开头**只有认不出标题行时**才算注释。
|
||||||
|
|
||||||
|
⚠ 顺序是"先当标题解析、再当注释":`#中秋快乐_19286456013_20260929` 是一条**正常标题**
|
||||||
|
(`#话题` 本来就长这样),过去的写法会把整行当注释**静默丢掉**。现在只有像
|
||||||
|
`# 这是注释` 这种解析不出手机号/日期的行才按注释忽略。
|
||||||
|
"""
|
||||||
rows, errors = [], []
|
rows, errors = [], []
|
||||||
lines = (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
lines = (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||||
for i, raw in enumerate(lines, start=1):
|
for i, raw in enumerate(lines, start=1):
|
||||||
s = raw.strip()
|
s = raw.strip()
|
||||||
if not s or s.startswith("#"):
|
if not s:
|
||||||
continue
|
continue
|
||||||
meta, err = parse_title_line(s)
|
meta, err = parse_title_line(s)
|
||||||
if err:
|
if err:
|
||||||
|
if s.startswith("#"): # 认不出 + 以 # 开头 → 当注释,静默跳过
|
||||||
|
continue
|
||||||
errors.append({"line": i, "reason": err, "raw": s[:60]})
|
errors.append({"line": i, "reason": err, "raw": s[:60]})
|
||||||
continue
|
continue
|
||||||
meta["_line"] = i
|
meta["_line"] = i
|
||||||
|
|||||||
+1
-1
@@ -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}` |
|
| `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/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` **先删行再删素材文件** |
|
||||||
|
|||||||
+3
-1
@@ -485,7 +485,9 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
|||||||
|
|
||||||
```
|
```
|
||||||
① 取本机账号(core.ledger.resolve_device —— "本机是哪台"只在那处定义,别写第二套)
|
① 取本机账号(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')
|
③ **原子占位**:UPDATE … SET status='pushing' WHERE id=? AND status IN ('ready','failed')
|
||||||
← **幂等的唯一判据**(多设备/任务重跑不会把同一条推两遍)
|
← **幂等的唯一判据**(多设备/任务重跑不会把同一条推两遍)
|
||||||
④ adb push → {album_dir}/{计划id}.mp4(默认 /sdcard/DCIM/Camera)→ 校验**大小**(按 ls -l 的大小列比)
|
④ adb push → {album_dir}/{计划id}.mp4(默认 /sdcard/DCIM/Camera)→ 校验**大小**(按 ls -l 的大小列比)
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ function rpShowTitleConfirm(fname, text) {
|
|||||||
document.getElementById('modal-box').classList.add('wide-modal');
|
document.getElementById('modal-box').classList.add('wide-modal');
|
||||||
document.getElementById('modal-body').innerHTML =
|
document.getElementById('modal-body').innerHTML =
|
||||||
'<div class="help" style="margin-bottom:6px">共 ' + lines.length + ' 行有效标题。'
|
'<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">'
|
+ '<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>'
|
+ esc(lines.slice(0, 200).join('\n')) + (lines.length > 200 ? '\n…' : '') + '</div>'
|
||||||
+ '<div id="rp-title-result"></div>';
|
+ '<div id="rp-title-result"></div>';
|
||||||
|
|||||||
@@ -691,7 +691,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> 开头的行忽略)。
|
标题 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>
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ def api_vp_upload_video():
|
|||||||
def api_vp_upload_titles():
|
def api_vp_upload_titles():
|
||||||
"""上传标题:`{text, dry_run}`;也接受 multipart 的 txt 文件(字段名 file)。
|
"""上传标题:`{text, dry_run}`;也接受 multipart 的 txt 文件(字段名 file)。
|
||||||
|
|
||||||
每行 `标题内容_手机号_日期_编号`(编号可省略、标题里可以有下划线);`#` 开头的行忽略。
|
每行 `标题内容_手机号_日期_编号` 或 `手机号_日期_编号_标题`(都认;编号可省略、标题里可以有下划线);
|
||||||
|
`#` 开头的行**只有认不出手机号/日期时才当注释**(`#话题` 开头的标题照收)。
|
||||||
"""
|
"""
|
||||||
if request.files.get("file"):
|
if request.files.get("file"):
|
||||||
raw = request.files["file"].stream.read()
|
raw = request.files["file"].stream.read()
|
||||||
|
|||||||
Reference in New Issue
Block a user