Просмотр исходного кода

fix: 对外链接host强制s3.fmode.cn(用户确认规则)

- common.py public_url(): 默认生成 s3.fmode.cn/<key>, 新增 verify_public_host() 发布时自检反代可达性
- publish.py: 发布后域名自检 + 逐产物HEAD校验(校验地址同走 s3.fmode.cn)
- publish_via_obs.py: 校验/链接从 OBS 原生域名改为 s3.fmode.cn
- SKILL.md: 交付规范硬性条款(唯一交付=个人空间公开读+群发链接, 禁发文件附件; 链接host铁律)
- references/s3-upload.md: 域名规范更新(上传协议层 vs 对外分发层分离)
- 新增 JS/ESM 演进第一批(JSS-01/02/03: type_classify / aigc_storyboard / aigc_report)
- 新增 AIGC 分镜拉片全流程(场景检测→批量视觉点评→能量分析→长提示词)
tuyetv 3 недель назад
Родитель
Сommit
67b28d89d0
9 измененных файлов с 683 добавлено и 26 удалено
  1. 22 1
      SKILL.md
  2. 117 0
      js/aigc_report.mjs
  3. 245 0
      js/aigc_storyboard.mjs
  4. 135 0
      js/type_classify.mjs
  5. 4 2
      references/s3-upload.md
  6. 24 19
      scripts/build_report.py
  7. 26 2
      scripts/common.py
  8. 3 2
      scripts/publish.py
  9. 107 0
      scripts/publish_via_obs.py

+ 22 - 1
SKILL.md

@@ -58,7 +58,7 @@ python3 $REPO/scripts/build_report.py --workdir $WD --title "拉片分析:XXX"
 
 # 5. 发布到自己的S3个人空间 → 得公开URL
 python3 $REPO/scripts/publish.py --workdir $WD
-# → https://storage-s3-nkkj.obs.cn-north-4.myhuaweicloud.com/user/<FEME_USERID>/report/lapian/douyin/<YYYYMMDD>/report.html
+# → https://s3.fmode.cn/user/<FEME_USERID>/report/lapian/douyin/<YYYYMMDD>/report.html
 ```
 
 ## 产物结构(S3 路径规范, 全英文小写)
@@ -96,6 +96,23 @@ user/<userid>/report/lapian/<平台简称>/<YYYYMMDD>/
 - 转写短路: transcript.json 已存在则跳过(转写按分钟计费, 别重复花钱)
 - 视频下载完整校验: ffprobe 时长异常即报错, 禁止截断件进入分析
 
+## JS/ESM 演进方向(JSS, 2026-08-30 起)
+
+核心函数逐步 JS/ESM 化(`js/` 目录, 纯 ESM 零依赖, Node/浏览器双端), Python 只保留必须的系统级操作(ffmpeg/网络/文件IO):
+
+- `js/type_classify.mjs` — **JSS-01 类型预判**: 拉片前先判类型。特征打分制(标签3分/标题2分/口播1分/节奏指纹2分/AIGC强制信号4分), 类型体系见 TYPES(aigc_music/aigc_shortdrama/aigc_animal/talking_head/vlog/drama/food/knowledge), 输出 `{primary, mode}` → mode 路由: `lapian`(方法论文拉片, 原流程) | `aigc_storyboard`(AIGC分镜仿制拉片, 新流程)。CLI: `node type_classify.mjs workdir` → classify.json
+- `js/aigc_storyboard.mjs` — **JSS-02 AIGC分镜+仿爆款长提示词**: ①歌词结构检测(detectLyricStructure, 段落优先级: sections.json 人工精修 > 自动检测) ②视觉注册表(人物/场景/风格词频提取) ③分镜表合成(shot×frame×lyric, 每镜含 remake_prompt 生词提示: 主体|场景|运镜|风格) ④六维评分 ⑤buildLongPrompt 生成可直接投喂 AIGC 创作智能体的长提示词({{核心人物}}/{{核心产品或概念}}占位符换皮复刻)。CLI: `node aigc_storyboard.mjs workdir` → aigc_remake.json + aigc_prompt.md
+- `js/aigc_report.mjs` — **JSS-03 AIGC拉片报告**: 暗色HTML, 分镜卡片(逐镜截图+生词提示+AI痕迹), 注册表/结构表/评分/长提示词/VOC全展示。CLI: `node aigc_report.mjs workdir` → aigc_report.html
+
+AIGC 拉片流程(mode=aigc_storyboard 时): 场景检测(`select='gt(scene,0.10)'` 双阈值, AIGC渐变转场阈值要低于真人剪辑) → 逐镜中点抽帧 → 批量视觉点评(4帧/批, AIGC专用提示词: char/scene/shot/style/aigc/desc 六字段) → 能量高潮分析(astats RMS 找副歌位) → 台词+唱段合成 sections.json → JSS-02/03。
+
+## 交付规范(硬性, 2026-08-30 用户确认)
+
+- **唯一标准交付 = 报告发布到用户 S3 个人空间(公开读) + 群里发公开链接**。不发文件附件(.md/.html 直传文件不是交付, 用户明确拒绝)。
+- **对外链接 host 必须是 `https://s3.fmode.cn/<key>`, 严禁用 OBS 原生域名 `storage-s3-nkkj.obs.cn-north-4.myhuaweicloud.com`** —— 报告的相对路径资源(iframe视频/截图/数据)只有经 s3.fmode.cn 反代才能被浏览器正常加载, OBS 原生域名的链接在浏览器里会加载异常。此规则 2026-08-29 用户已沟通, 任何"公开URL生成点"(public_url/publish脚本/临时拼URL)一律走 s3.fmode.cn。上传/签名的 host 仍是 OBS 原生(协议层), 只有**对外分发的链接**用 s3.fmode.cn。
+- 发布是流程必经步(第5步), 发布失败 = 流程未完成: 必须修复发布通道后重试, 或明确告知用户"发布被阻断+原因", 不得静默改用文件附件兜底。
+- 链接格式: `https://s3.fmode.cn/user/<userid>/report/lapian/<平台>/<YYYYMMDD>/<报告名>.html`(口播流程 report.html / AIGC 流程 aigc_report.html)。发布后必须逐产物 HEAD 200 校验通过(校验也用 s3.fmode.cn!)才算完成, 校验通过才发链接。
+
 ## 坑(实测)
 
 - VOC 网关 401 先查 token 是否 `sk-` 开头且 Bearer 前缀完整
@@ -104,3 +121,7 @@ user/<userid>/report/lapian/<平台简称>/<YYYYMMDD>/
 - fmode-listen 是异步转写, 长音频耐心等(脚本已设 30 分钟超时)
 - 豆包视觉模型: 用 `doubao-seed-2-0-pro-260215`(当前账号唯一可用豆包pro), DeepSeek 系无视觉
 - 同一视频当天重跑: publish.py 幂等覆盖同日期目录, 直接重传即可
+- **抖音下载铁律(2026-08-29 三次坏文件实测)**: `play_addr` 流对非APP客户端返回**损坏流**(moov 声称259s但 mdat 截断, ffprobe 时长正常但解码报 Invalid NAL unit size)。**必须用 `download_addr.url_list[0]` + curl 带浏览器UA/Referer 下载**(该流是高码率 1958kbps 版本, 时长可能与 play 版差几秒)。**唯一可信校验 = `ffmpeg -xerror -i 全量解码`, ffprobe 时长正常/尾部分段解码都可能假阴性**。下载会话中断后转写/抽帧全部作废重跑(音频只覆盖已下载部分)。
+- **孤儿进程陷阱**: 会话中断后下载进程可能还活着继续写文件, 恢复会话先 `ps aux | grep fetch_video` + 隔几秒对比文件大小, 确认没有写入者再动手, 避免双写或误判。
+- **报告点评 LLM 空响应**: build_report.py 内置 call_llm 走 deepseek-v4-flash 会路由到 reasoning 模型, reasoning 吃满 max_tokens 导致 content 为空。解法: 自己(编排 agent)按 methodology.md 逐段写点评 → 写 `workdir/llm_out.json`(格式: `{"segments":[{"i","techniques","func","comment","reuse"}...], "structure_summary"}`) → build_report.py 自动优先采用(手动 > LLM > 启发式)。
+- **S3 发布的 storage API 是设计稿未部署**(2026-08-29 实测): `/api/storage/credentials`、`/api/storage/upload` 在 443 网关与各 studio 端口全部 404, fmode-identity.json 也无 CLOUD_SDK_AK/SK。**用跳板机通道: `scripts/publish_via_obs.py`** — 容器打包 → SFTP 到 server.fmode.cn(139.159.253.131) → 云端 obsutil(`/opt/obsutil/obsutil_linux_amd64_5.4.11/obsutil`, 自带 nkkj 主账套 AK/SK, `cp -acl=public-read -e=obs.cn-north-4...`, 旗标别加 `-p -f`) → OBS 公网 HEAD 校验。paramiko 连云服务器偶发 SSH banner 瞬断, 脚本已带重试。注意容器 tar 打包时 assets 类文件本地在根目录、需 arcname 映射到 assets/ 前缀。

+ 117 - 0
js/aigc_report.mjs

@@ -0,0 +1,117 @@
+/**
+ * aigc_report.mjs — AIGC 分镜拉片报告生成器(JSS-03)
+ * 读取 aigc_remake.json + aigc_prompt.md → 产出 aigc_report.html(暗色/手机自适应/相对路径)
+ * 纯 ESM, 零依赖。CLI: node aigc_report.mjs workdir
+ */
+import fs from "node:fs";
+
+const esc = (s) => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
+const fmtTs = (ms) => { const s = Math.floor(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
+
+function scoreBars(dims) {
+  return dims.map((d) => `
+    <div class="dim"><div class="dim-head"><span>${esc(d.name)}</span><span>${d.score}</span></div>
+    <div class="bar"><i style="width:${d.score}%"></i></div><div class="note">${esc(d.note)}</div></div>`).join("");
+}
+
+function shotRows(board) {
+  return board.map((s) => {
+    const img = s.file ? `frames/${s.file}` : "";
+    return `<details class="shot"><summary>
+      <span class="idx">#${s.i + 1}</span><span class="t">${esc(s.t_label)}</span>
+      <span class="dur">${s.dur}s</span><span class="role">${esc(s.role.split("(")[0])}</span></summary>
+      <div class="shot-body">
+        ${img ? `<img loading="lazy" src="${img}" alt="shot ${s.i}">` : ""}
+        <p><b>画面:</b> ${esc(s.caption)}</p>
+        ${s.lyric ? `<p><b>音频:</b> ${esc(s.lyric)}</p>` : ""}
+        ${s.vision?.char ? `<p><b>主体:</b> ${esc(s.vision.char)}</p>` : ""}
+        ${s.vision?.shot ? `<p><b>运镜:</b> ${esc(s.vision.shot)}</p>` : ""}
+        ${s.vision?.style ? `<p><b>风格:</b> ${esc(s.vision.style)}</p>` : ""}
+        ${s.vision?.aigc ? `<p class="aigc"><b>AI痕迹:</b> ${esc(s.vision.aigc)}</p>` : ""}
+      </div></details>`;
+  }).join("");
+}
+
+export function render(remake, promptMd) {
+  const { video, type, structure, registry, storyboard, score, voc } = remake;
+  const st = video.stats || {};
+  return `<!DOCTYPE html><html lang="zh-CN" data-theme="dark"><head><meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>AIGC分镜拉片:${esc((video.desc || "").split("#")[0].trim())}</title><style>
+:root{--bg:#0a0e17;--surface:#141a28;--line:#232c42;--tx:#dbe4f5;--dim:#8b98b8;--acc:#7c6cff;--gold:#ffb84d;--green:#3ddc97;--red:#ff6b7a}
+*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--tx);font:15px/1.7 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
+.wrap{max-width:860px;margin:0 auto;padding:16px}
+h1{font-size:20px}h2{font-size:17px;border-left:3px solid var(--acc);padding-left:10px;margin-top:32px}
+.card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px;margin:10px 0}
+.stats{display:flex;flex-wrap:wrap;gap:8px}.stat{flex:1;min-width:90px;text-align:center;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:10px 4px}
+.stat b{display:block;font-size:18px;color:var(--gold)}.stat span{font-size:12px;color:var(--dim)}
+.badge{display:inline-block;background:var(--acc);color:#fff;border-radius:20px;padding:2px 12px;font-size:13px;margin-right:6px}
+video{width:100%;border-radius:12px;margin:10px 0;background:#000}
+details.shot{background:var(--surface);border:1px solid var(--line);border-radius:10px;margin:6px 0;overflow:hidden}
+details.shot summary{cursor:pointer;padding:9px 12px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;list-style:none}
+details.shot summary::-webkit-details-marker{display:none}
+.idx{color:var(--acc);font-weight:700;min-width:36px}.t{color:var(--dim);font-size:13px}.dur{color:var(--gold);font-size:13px}
+.role{font-size:12px;color:var(--dim)}
+.shot-body{padding:4px 14px 14px}.shot-body img{width:100%;border-radius:8px;margin:6px 0}
+.shot-body p{margin:4px 0;font-size:14px}.aigc{color:var(--red)}
+.bar{height:8px;background:var(--line);border-radius:4px;overflow:hidden}.bar i{display:block;height:100%;background:linear-gradient(90deg,var(--acc),var(--gold))}
+.dim{margin:10px 0}.dim-head{display:flex;justify-content:space-between;font-size:14px}.note{font-size:12px;color:var(--dim)}
+table{width:100%;border-collapse:collapse;font-size:13px}th,td{border:1px solid var(--line);padding:6px 8px;text-align:left}th{color:var(--gold);font-weight:600}
+pre{white-space:pre-wrap;word-break:break-word;background:#0d1220;border:1px solid var(--line);border-radius:10px;padding:14px;font-size:13px;max-height:420px;overflow:auto}
+a{color:var(--acc)}.sec{font-size:13px;color:var(--dim)}
+footer{text-align:center;color:var(--dim);font-size:12px;padding:24px 0}
+</style></head><body><div class="wrap">
+<h1>🕷️ AIGC 分镜拉片报告</h1>
+<div class="sec">${esc(video.author)} · <a href="${esc(video.url)}">原片</a> · ${fmtTs(video.duration_s * 1000)}</div>
+<div style="margin:10px 0">
+  <span class="badge">${esc(type.primaryLabel || "AIGC内容")}</span>
+  <span class="badge" style="background:var(--gold);color:#333">置信度 ${type.confidence ?? "?"}</span>
+  <span class="badge" style="background:#2a3350">复刻模式: 分镜仿制</span>
+</div>
+<video controls preload="metadata" src="video.mp4"></video>
+<div class="stats">
+  <div class="stat"><b>${(st.digg_count ?? 0).toLocaleString()}</b><span>👍 点赞</span></div>
+  <div class="stat"><b>${(st.comment_count ?? 0).toLocaleString()}</b><span>💬 评论</span></div>
+  <div class="stat"><b>${(st.collect_count ?? 0).toLocaleString()}</b><span>🔖 收藏</span></div>
+  <div class="stat"><b>${(st.share_count ?? 0).toLocaleString()}</b><span>↗ 转发</span></div>
+  <div class="stat"><b>${score.total}</b><span>综合评分</span></div>
+</div>
+
+<h2>🧬 复刻资产注册表</h2>
+<div class="card"><b>人物:</b> ${(registry.characters || []).map(esc).join("、") || "—"}<br>
+<b>场景:</b> ${(registry.scenes || []).map(esc).join("、") || "—"}<br>
+<b>风格:</b> ${(registry.styles || []).map(esc).join("、") || "—"}<br>
+<b>节奏:</b> ${structure.shot_count} 镜 / 平均 ${structure.avg_shot_s}s / ${(structure.shot_count / (video.duration_s / 60)).toFixed(0)} 镜每分钟</div>
+
+<h2>🎬 片体结构</h2>
+<table><tr><th>段落</th><th>时间</th><th>类型</th><th>内容</th></tr>
+${(structure.sections || []).map((s) => `<tr><td>${esc(s.name)}</td><td class="t">${s.start}s–${s.end}s</td><td>${esc(s.type || "")}</td><td>${esc(s.content || s.lyric || "")}</td></tr>`).join("")}
+</table>
+
+<h2>🎞️ 分镜表(${storyboard.length} 镜)</h2>
+${shotRows(storyboard)}
+
+<h2>📊 六维评分</h2>
+<div class="card">${scoreBars(score.dims)}</div>
+
+<h2>🧾 仿爆款长提示词</h2>
+<div class="sec">完整版: <a href="aigc_prompt.md" download>aigc_prompt.md</a> — 直接投喂给 AIGC 创作智能体, 替换占位符即可逐镜复刻</div>
+<pre>${esc(promptMd)}</pre>
+
+<h2>💬 评论区 VOC</h2>
+<div class="card">${(voc?.top || []).map((c) => `<div style="border-bottom:1px solid var(--line);padding:6px 0"><b style="color:var(--gold)">👍${c.likes}</b> ${esc(c.text)}</div>`).join("") || "—"}
+<div class="sec" style="margin-top:6px">共采集 ${voc?.total || 0} 条</div></div>
+
+<h2>🚀 改进与复刻策略</h2>
+<div class="card">${(remake.suggestions || []).map((s) => `<p>▸ ${esc(s)}</p>`).join("")}</div>
+<footer>skill-video-lapian · JSS-01/02/03 · ${new Date().toISOString().slice(0, 10)}</footer>
+</div></body></html>`;
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  const wd = process.argv[2] || ".";
+  const remake = JSON.parse(fs.readFileSync(`${wd}/aigc_remake.json`, "utf8"));
+  const promptMd = fs.existsSync(`${wd}/aigc_prompt.md`) ? fs.readFileSync(`${wd}/aigc_prompt.md`, "utf8") : "(未生成)";
+  fs.writeFileSync(`${wd}/aigc_report.html`, render(remake, promptMd));
+  console.log(`→ ${wd}/aigc_report.html (${(fs.statSync(`${wd}/aigc_report.html`).size / 1024).toFixed(0)} KB)`);
+}

+ 245 - 0
js/aigc_storyboard.mjs

@@ -0,0 +1,245 @@
+/**
+ * aigc_storyboard.mjs — AIGC 短剧/音乐分镜拆解与仿爆款长提示词生成(JSS-02)
+ *
+ * 输入(workdir 下):
+ *   shots.json      {duration, shot_count, shots:[{i,start,end,dur}]}
+ *   frames.json     [{i, t_ms, t_label, caption}]   ← 视觉逐帧点评(python 管线产出)
+ *   transcript.json {data:{segments:[{bg,ed,text}], text}}
+ *   meta.json       {desc, author, stats, tags, duration_ms}
+ *   comments.json   [{text, likes, nickname}]       ← 可选, VOC
+ *   classify.json   {primary, mode}                 ← type_classify.mjs 产出
+ *
+ * 输出(workdir 下):
+ *   aigc_remake.json  结构化分镜库(人物/场景/风格/节奏注册表 + 分镜表)
+ *   aigc_prompt.md    可直接投喂 AIGC 创作智能体的长提示词(带 {{核心人物}}/{{核心产品}} 占位符)
+ *
+ * 纯 ESM / 零依赖 / Node 与浏览器双端。
+ */
+
+// ---------- 工具 ----------
+const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
+const fmtTs = (ms) => { const s = Math.floor(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
+const uniq = (a) => [...new Set(a)];
+const freq = (arr) => { const m = {}; for (const x of arr) m[x] = (m[x] || 0) + 1; return m; };
+const topN = (m, n) => Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, n).map(([k, v]) => ({ k, v }));
+
+// ---------- 歌词结构: 副歌检测(重复行聚类) ----------
+export function detectLyricStructure(segments) {
+  const lines = segments.map((s) => ({ t: s.bg, text: (s.text || "").trim() })).filter((l) => l.text.length > 1);
+  const norm = (t) => t.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "").toLowerCase();
+  const counts = freq(lines.map((l) => norm(l.text)));
+  const repeated = new Set(Object.keys(counts).filter((k) => counts[k] >= 2 && k.length >= 3));
+  // 连续重复区段 = 副歌
+  const sections = [];
+  let cur = null;
+  for (const l of lines) {
+    const isChorus = repeated.has(norm(l.text));
+    if (!cur || cur.chorus !== isChorus) {
+      if (cur) sections.push(cur);
+      cur = { chorus: isChorus, start: l.t, end: l.ed ?? l.t + 4000, lines: [l.text] };
+    } else { cur.lines.push(l.text); cur.end = l.ed ?? l.t + 4000; }
+  }
+  if (cur) sections.push(cur);
+  // 段落命名: 副歌/主歌/间奏(无词)
+  let v = 0, c = 0;
+  return sections.map((s, idx) => {
+    let name;
+    if (s.chorus) name = `副歌${++c || ""}`;
+    else if (s.lines.join("").length < 4) name = "间奏/纯音乐";
+    else name = `主歌${++v}`;
+    return { idx, name, chorus: s.chorus, start: s.start, end: s.end, lyric: s.lines.join(" / ").slice(0, 120) };
+  });
+}
+
+// ---------- 视觉注册表: 人物/场景/风格提取 ----------
+const CHAR_HINTS = ["女王", "女团", "和声", "舞者", "观众", "歌手", "男主", "女主", "主角", "机器人", "蜘蛛", "乐手", "乐队", "伴舞"];
+const SCENE_HINTS = ["舞台", "观众席", "后台", "城市", "街道", "夜景", "室内", "棚", "天空", "废墟", "宫殿", "洞穴", "森林", "海边", "机房", "大厅"];
+const STYLE_HINTS = ["霓虹", "赛博", "金属", "烟雾", "激光", "追光", "灯光", "金色", "红色", "紫色", "蓝色", "暗调", "高饱和", "质感", "毛发", "鳞片", "机械", "全息", "粒子", "镜头", "特写", "远景", "俯拍", "仰拍", "推近", "拉远"];
+
+function extractRegistry(captions) {
+  const text = captions.join(" ");
+  const pick = (hints) => topN(freq(hints.filter((h) => text.includes(h))), 10).map((x) => x.k);
+  return {
+    characters: pick(CHAR_HINTS),
+    scenes: pick(SCENE_HINTS),
+    styles: pick(STYLE_HINTS),
+  };
+}
+
+// ---------- 分镜表合成: shot × frame × lyric × section ----------
+export function buildStoryboard({ shots = [], frames = [], segments = [], sections = [] } = {}) {
+  const framesByShot = new Map(frames.map((f) => [f.i, f]));
+  const seenSegs = new Set();
+  const board = shots.map((s) => {
+    const t0 = Math.round(s.start * 1000), t1 = Math.round(s.end * 1000);
+    const frame = framesByShot.get(s.i) || {};
+    const lyricSegs = segments
+      .filter((g) => g.bg < t1 && (g.ed || g.bg + 4000) > t0);
+    // 每个转写段只在它覆盖的第一个镜头显示全文, 后续镜头显示〃(避免长段刷屏)
+    let lyric = "";
+    if (lyricSegs.length) {
+      const key = lyricSegs[0].bg;
+      lyric = seenSegs.has(key) ? "〃(同段)" : lyricSegs[0].text.trim();
+      seenSegs.add(key);
+    }
+    return {
+      i: s.i, start: s.start, end: s.end, dur: s.dur,
+      t_label: `${fmtTs(t0)}-${fmtTs(t1)}`,
+      caption: frame.caption || "(待视觉点评)",
+      lyric,
+      // 镜头角色推断
+      role: s.dur >= 4 ? "展示镜头(副歌记忆点/氛围铺陈)" :
+            s.dur >= 2 ? "叙事镜头(主体动作/歌词同步)" : "节奏镜头(快切/卡点/反应)",
+    };
+  });
+  // 段落归属(sections 秒坐标)
+  for (const s of board) {
+    const sec = sections.find((x) => s.start >= x.start && s.start < x.end);
+    s.section = sec ? sec.name : "纯音乐";
+    // AIGC 复刻提示: 视觉点评要素合成(可直接作为单镜生词提示词)
+    const v = framesByShot.get(s.i)?.vision || {};
+    s.remake_prompt = [v.char, v.scene, v.shot, v.style].filter(Boolean).join(" | ") ||
+      s.caption;
+  }
+  return board;
+}
+
+// ---------- 评分体系(6维, 保留口播版的评分传统) ----------
+export function scoreVideo({ meta = {}, shots = [], frames = [], lyricDensity = 0 } = {}) {
+  const stats = meta.stats || {};
+  const digg = stats.digg_count || 0, share = stats.share_count || 0, collect = stats.collect_count || 0;
+  const shareRate = digg ? share / digg : 0;
+  const durs = shots.map((s) => s.dur);
+  const avgShot = durs.length ? durs.reduce((a, b) => a + b, 0) / durs.length : 0;
+  const visionCoverage = shots.length ? frames.filter((f) => f.caption && !f.caption.startsWith("(待")).length / shots.length : 0;
+  const dims = [
+    { name: "传播力(转发/赞比)", score: clamp(Math.round(shareRate * 100), 1, 100), note: `转发/点赞=${(shareRate * 100).toFixed(0)}%` },
+    { name: "收藏价值(模仿意愿)", score: clamp(Math.round((collect / Math.max(digg, 1)) * 200), 1, 100), note: `收藏/点赞=${((collect / Math.max(digg, 1)) * 100).toFixed(0)}%` },
+    { name: "节奏设计", score: clamp(Math.round(avgShot ? 110 - avgShot * 12 : 40), 1, 100), note: `平均镜头${avgShot.toFixed(1)}s` },
+    { name: "镜头信息密度", score: clamp(Math.round(shots.length / 4), 1, 100), note: `${shots.length}个镜头` },
+    { name: "音乐画面同步", score: clamp(Math.round(lyricDensity * 100) || 60, 1, 100), note: `歌词覆盖率${(lyricDensity * 100).toFixed(0)}%` },
+    { name: "视觉可复制度", score: clamp(Math.round(visionCoverage * 100), 1, 100), note: `视觉点评覆盖${(visionCoverage * 100).toFixed(0)}%` },
+  ];
+  const total = Math.round(dims.reduce((s, d) => s + d.score, 0) / dims.length);
+  return { dims, total };
+}
+
+// ---------- 仿爆款长提示词生成 ----------
+export function buildLongPrompt({ meta = {}, classify = {}, registry = {}, board = [], sections = [], score = {} } = {}) {
+  const chorusSecs = sections.filter((s) => s.chorus);
+  const dialogSecs = sections.filter((s) => s.type === "dialog" && s.content);
+  const styleLine = registry.styles?.length ? registry.styles.slice(0, 8).join("、") : "电影感打光、高饱和舞台色";
+  const charLine = registry.characters?.length ? registry.characters.slice(0, 6).join("、") : "核心角色、伴演群体";
+  const sceneLine = registry.scenes?.length ? registry.scenes.slice(0, 5).join("、") : "主舞台、观众席";
+  const isVariety = sections.some((s) => s.type === "dialog" || s.type === "mixed");
+  const genreLine = isVariety
+    ? `**混合体裁**: 「音乐现场 × 综艺选秀」结构(台词报幕/评委互动/晋级投票 + 唱段表演) —— 这是 AIGC 内容的体裁创新点, 复刻时必须保留综艺叙事骨架`
+    : "**纯音乐现场**结构(前奏→主歌→副歌→间奏→收尾)";
+  const shotLines = board.map((s) => {
+    // sections 用秒, board.start 也是秒
+    const sec = sections.find((x) => s.start >= x.start && s.start < x.end);
+    const secName = sec ? sec.name : "纯音乐";
+    return `| ${s.i + 1} | ${s.t_label} | ${s.dur}s | ${secName} | ${s.role.split("(")[0]} | ${s.remake_prompt || s.caption} | ${s.lyric ? `「${s.lyric.slice(0, 28)}${s.lyric.length > 28 ? "…" : ""}」` : "(音乐)"} |`;
+  });
+  return `# AIGC 爆款复刻 · 长提示词
+> 源片: ${meta.author || "?"}《${(meta.desc || "").split("#")[0].trim()}》(${fmtTs(meta.duration_ms || 0)}, ${classify.primaryLabel || "AIGC内容"})
+> 用法: 整段投喂给 AIGC 视频创作智能体, 替换 {{核心人物}} / {{核心产品或概念}} 后按分镜表逐镜生成。
+
+## 一、角色设定(全局一致性锚点)
+- 核心人物: {{核心人物}} —— 原片为「${charLine}」; 复刻时保持: 形象特征贯穿全片不变(服装/配色/标志物/体型比例), 每个镜头生成时必须回读此锚点。
+- 核心产品或概念: {{核心产品或概念}} —— 原片为「音乐翻唱现场」; 替换后所有镜头的叙事焦点围绕它展开。
+- 群像: ${charLine}
+
+## 二、全局视觉风格(所有分镜共用)
+- 风格关键词: ${styleLine}
+- 画面基调: 舞台级打光、${sceneLine} 为主场景、强色彩记忆点
+- 画质要求: 电影级质感、4K、细节丰富、无文字水印、人物面部稳定不畸变
+- 负面约束: 画面抖动、人物变形、多指、闪烁、风格漂移(每个镜头生成后与上一镜头对比主体一致性)
+
+## 三、音频结构与体裁(复刻节拍骨架)
+- 体裁: ${genreLine}
+- 原片段落: ${sections.map((s) => s.name).slice(0, 8).join(" → ")}${sections.length > 8 ? " → …" : ""}
+- 换曲复刻: 保留「开场抓人→表演高潮→互动喘息→抉择收尾」的情绪曲线, 镜头时长配比可复用原片节奏
+${dialogSecs.length ? `
+## 三·B、台词本(综艺骨架, 换皮复刻)
+> 原片主持/评委台词(ASR转写), 复刻时按「报幕→悬念→互动→抉择」功能位替换内容:
+${dialogSecs.map((s) => `- 【${s.name}】${(s.content || "").slice(0, 100)}`).join("\n")}
+` : ""}
+## 四、分镜表(共 ${board.length} 镜, 直接逐行生成)
+| # | 时间 | 时长 | 段落 | 镜头角色 | 逐镜生词提示(主体\\|场景\\|运镜\\|风格) | 歌词/音频同步 |
+|---|---|---|---|---|---|---|
+${shotLines.join("\n")}
+
+## 五、逐镜生成规则
+1. 每个镜头独立生成, 但开头必须复述「角色锚点+风格关键词」再描述本镜画面(防风格漂移)。
+2. 镜头角色含义: 展示镜头=大景别+氛围渲染+角色高光; 叙事镜头=中景+动作与歌词对齐; 节奏镜头=1秒内快切, 用于卡鼓点/反应镜头。
+3. 副歌段落镜头优先分配最佳预算(最高画质/最复杂运镜)。
+4. 快切镜头(<1s)可用上一镜头尾帧图作为图生视频起点, 保证连贯。
+5. 全部镜头生成后按分镜表时间轴拼接, 音轨对齐歌词时间戳。
+
+## 六、交付规格
+- 画幅: 9:16 竖屏 · 时长: 对齐原曲结构(可等比缩放) · 帧率: 24/30fps
+- 拼接后全片检查: 主体一致性 / 色调统一 / 节奏与音乐对齐
+
+## 七、原片成绩(复刻目标基准)
+- ${meta.stats?.digg_count || "?"} 赞 · ${meta.stats?.share_count || "?"} 转发 · ${meta.stats?.collect_count || "?"} 收藏 · 综合${score.total ?? "?"}分
+- 爆款要素: 高密度快剪(平均镜头${(durs(board)).toFixed(1)}s) + 强视觉奇观(拟人角色舞台) + 热歌情感嫁接
+
+---
+*由 skill-video-lapian/JSS-02 aigc_storyboard.mjs 自动生成*`;
+}
+const durs = (board) => board.length ? board.reduce((s, x) => s + x.dur, 0) / board.length : 0;
+
+// ---------- 主入口: 组装 aigc_remake.json + aigc_prompt.md ----------
+export async function generate(workdir) {
+  const fs = await import("node:fs");
+  const safe = (n) => { try { return JSON.parse(fs.readFileSync(`${workdir}/${n}`, "utf8")); } catch { return null; } };
+  const shots = safe("shots.json") || {};
+  const frames = safe("frames.json") || [];
+  const tr = safe("transcript.json") || {};
+  const segments = tr?.data?.segments || [];
+  const meta = safe("meta.json") || {};
+  const comments = safe("comments.json") || [];
+  const classify = safe("classify.json") || {};
+
+  // 段落结构优先级: 人工精修 sections.json > 歌词重复自动检测
+  const sections = safe("sections.json")?.sections || detectLyricStructure(segments);
+  const registry = extractRegistry(frames.map((f) => f.caption || "").concat((meta.desc || "").split(/[,。#\s]+/)));
+  const board = buildStoryboard({ shots: shots.shots || [], frames, segments, sections });
+  const lyricCov = segments.length ? board.filter((s) => s.lyric).length / Math.max(board.filter((s) => s.lyric !== "").length || board.length, 1) : 0;
+  const score = scoreVideo({ meta, shots: board, frames, lyricDensity: clamp(lyricCov, 0, 1) });
+
+  // 自动改进/复刻策略
+  const stats = meta.stats || {};
+  const spm = Math.round(board.length / ((shots.duration || 1) / 60));
+  const suggestions = [
+    `复刻要点: 保持 ${board.length} 镜/${spm}镜每分钟的高密度节奏, 展示镜头(≥4s)只留 ${board.filter((s) => s.dur >= 4).length} 个用作记忆点, 其余快切卡点`,
+    `角色一致性是 AIGC 成败关键: 全片主体(${(registry.characters || []).slice(0, 3).join("/")})必须逐镜回读锚点, 建议首镜生图后用图生视频保持形象`,
+    `音频驱动剪辑: 先定歌曲段落结构(本片 ${sections.length} 段), 镜头切换点对齐鼓点/段落边界, 而非自由分配`,
+    stats.share_count > stats.collect_count
+      ? `传播设计: 本片转发(${stats.share_count})远超收藏(${stats.collect_count}), 爆点在「社交谈资」(熟悉IP意外混搭) — 复刻时选认知度高的IP做反差组合`
+      : `传播设计: 收藏偏高说明「教程感/复用价值」强, 复刻时在结尾加可复用的模板感设计`,
+    `发布策略: 挂 #ai创作浪潮计划 等官方活动标签 + 音乐原声热歌, 是本赛道的流量杠杆`,
+  ];
+  const remake = {
+    video: { aweme_id: meta.aweme_id, desc: meta.desc, author: meta.author, duration_s: (shots.duration || 0), url: `https://www.douyin.com/video/${meta.aweme_id}` },
+    type: classify,
+    structure: { sections, shot_count: board.length, avg_shot_s: +(durs(board)).toFixed(2) },
+    registry,
+    storyboard: board,
+    score,
+    suggestions,
+    voc: { total: comments.length, top: comments.slice(0, 5).map((c) => ({ likes: c.likes, text: (c.text || "").slice(0, 60) })) },
+  };
+  fs.writeFileSync(`${workdir}/aigc_remake.json`, JSON.stringify(remake, null, 1));
+  fs.writeFileSync(`${workdir}/aigc_prompt.md`, buildLongPrompt({ meta, classify, registry, board, sections, score }));
+  return remake;
+}
+
+// CLI
+if (import.meta.url === `file://${process.argv[1]}`) {
+  const wd = process.argv[2] || ".";
+  const r = await generate(wd);
+  console.log(`分镜 ${r.structure.shot_count} | 段落 ${r.structure.sections.length} | 综合 ${r.score.total} 分`);
+  console.log(`→ ${wd}/aigc_remake.json + ${wd}/aigc_prompt.md`);
+}

+ 135 - 0
js/type_classify.mjs

@@ -0,0 +1,135 @@
+/**
+ * type_classify.mjs — 短视频类型预判(JSS-01)
+ *
+ * 输入: meta(标题/标签/作者简介/时长/数据) + transcript(口播词, 可选) + sceneStats(镜头统计, 可选)
+ * 输出: { primary, secondary[], confidence, evidence[], mode }
+ *   mode = "lapian"(方法论文拉片) | "aigc_storyboard"(AIGC分镜仿制拉片)
+ *
+ * 设计原则: 特征打分制, 每类有 title/tags/pattern/vo/duration 五维证据;
+ * 无 LLM 依赖, 纯规则, 可在浏览器/Node 双端运行(纯 ESM, 零 import)。
+ */
+
+// ---- 短视频类型体系(2026-08 平台实践版) -------------------------------
+export const TYPES = {
+  talking_head: {
+    label: "真人口播", mode: "lapian",
+    patterns: ["口播", "观点", "干货", "认知", "思维", "老板", "创业", "商业", "财经", "成长", "IP", "操盘", "变现", "生意"],
+    voHints: ["我", "我们", "为什么", "其实", "本质", "认知", "普通人", "记住"],
+  },
+  aigc_music: {
+    label: "AIGC音乐现场", mode: "aigc_storyboard",
+    patterns: ["ai", "aigc", "翻唱", "演唱", "现场", "音乐", "MV", "演唱会", "歌手", "和声", "舞台", "音综", "选秀", "综艺", "封神"],
+    voHints: [],
+  },
+  aigc_shortdrama: {
+    label: "AIGC短剧", mode: "aigc_storyboard",
+    patterns: ["短剧", "剧情", "逆袭", "战神", "赘婿", "重生", "霸道", "复仇", "穿越", " ads ", "AI短剧"],
+    voHints: ["他", "她", "只听", "只见", "这时", "下一秒", "众人"],
+  },
+  aigc_animal: {
+    label: "AIGC动物拟人", mode: "aigc_storyboard",
+    patterns: ["动物", "猫咪", "狗", "熊猫", "蜘蛛", "蛇", "海洋", "森林", "拟人", "童话"],
+  },
+  vlog: {
+    label: "真人Vlog", mode: "lapian",
+    patterns: ["vlog", "日常", "记录", "探店", "旅行", "一天", "生活"],
+  },
+  drama: {
+    label: "真人剧情/段子", mode: "lapian",
+    patterns: ["剧情", "段子", "反转", "夫妻", "婆媳", "职场", "搞笑"],
+  },
+  food: {
+    label: "美食", mode: "lapian",
+    patterns: ["美食", "探店", "做法", "食谱", "菜谱", "吃播", "食材"],
+  },
+  knowledge: {
+    label: "知识科普", mode: "lapian",
+    patterns: ["科普", "知识", "历史", "物理", "宇宙", "解构", "解读", "解读"],
+  },
+};
+
+// 镜头节奏指纹(镜头/分钟): 口播≈5-15, 剧情叙事≈15-25, 音乐现场/卡点≈25-90
+export function rhythmScore(shotsPerMin) {
+  if (shotsPerMin >= 45) return { aigcLikely: true, label: "超高密度卡点(音乐/燃向)" };
+  if (shotsPerMin >= 22) return { aigcLikely: true, label: "高密度快剪(音乐现场/混剪)" };
+  if (shotsPerMin >= 12) return { aigcLikely: false, label: "中密度叙事" };
+  return { aigcLikely: false, label: "低密度口播/长take" };
+}
+
+export function classify({ meta = {}, transcript = "", sceneStats = {} } = {}) {
+  const title = `${meta.desc || ""} ${meta.author_signature || ""}`.toLowerCase();
+  const tags = (meta.tags || []).join(" ").toLowerCase();
+  const text = transcript.toLowerCase();
+  const evidence = [];
+  const scores = {};
+  for (const k of Object.keys(TYPES)) scores[k] = 0;
+
+  // 1) 标签强证据(权重3, 直接命中类型名或同义词)
+  for (const [key, def] of Object.entries(TYPES)) {
+    for (const p of def.patterns) {
+      const needle = p.toLowerCase().trim();
+      if (!needle) continue;
+      if (tags.includes(needle)) {
+        scores[key] += 3;
+        evidence.push(`标签命中[${def.label}]: #${needle}`);
+      } else if (title.includes(needle)) {
+        scores[key] += 2;
+        evidence.push(`标题/简介命中[${def.label}]: "${needle}"`);
+      }
+    }
+  }
+  // 2) 口播词特征(权重1)
+  if (text) {
+    for (const [key, def] of Object.entries(TYPES)) {
+      const hits = (def.voHints || []).filter((h) => text.includes(h)).length;
+      if (hits >= 2) {
+        scores[key] += 1;
+        evidence.push(`口播特征[${def.label}] ${hits}处`);
+      }
+    }
+  }
+  // 3) 镜头节奏指纹(权重2, aigc_storyboard 系共分)
+  if (sceneStats.shotsPerMin != null) {
+    const r = rhythmScore(sceneStats.shotsPerMin);
+    evidence.push(`节奏指纹: ${sceneStats.shotsPerMin}/分 → ${r.label}`);
+    if (r.aigcLikely) {
+      for (const k of ["aigc_music", "aigc_shortdrama", "aigc_animal"]) scores[k] += 2;
+    }
+  }
+  // 4) AIGC 水印/标签强制信号
+  const aigcMarkers = ["ai创作浪潮计划", "aigc", "ai绘画", "ai视频", "ai翻唱", "ai音乐", "即梦", "可灵", "vidu", "runway", "sora"];
+  for (const mk of aigcMarkers) {
+    if (tags.includes(mk) || title.includes(mk)) {
+      for (const k of ["aigc_music", "aigc_shortdrama", "aigc_animal"]) scores[k] += 4;
+      evidence.push(`AIGC强制信号: ${mk}`);
+      break;
+    }
+  }
+
+  const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]).filter(([, v]) => v > 0);
+  const primary = ranked[0]?.[0] || "talking_head";
+  const confidence = ranked.length
+    ? Math.min(0.99, ranked[0][1] / Math.max(1, ranked.slice(0, 2).reduce((s, [, v]) => s + v, 0)))
+    : 0.3;
+  const def = TYPES[primary];
+  return {
+    primary, primaryLabel: def.label, mode: def.mode,
+    secondary: ranked.slice(1, 3).map(([k]) => k),
+    scores: ranked, confidence: Math.round(confidence * 100) / 100,
+    evidence: [...new Set(evidence)].slice(0, 12),
+  };
+}
+
+// CLI: node type_classify.mjs workdir
+if (import.meta.url === `file://${process.argv[1]}`) {
+  const fs = await import("node:fs");
+  const wd = process.argv[2] || ".";
+  const safeJSON = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return {}; } };
+  const meta = safeJSON(`${wd}/meta.json`);
+  const transcript = safeJSON(`${wd}/transcript.json`)?.data?.text || "";
+  const s = safeJSON(`${wd}/shots.json`);
+  const sceneStats = s.shot_count ? { shotsPerMin: Math.round(s.shot_count / (s.duration / 60)) } : {};
+  const r = classify({ meta, transcript, sceneStats });
+  console.log(JSON.stringify(r, null, 2));
+  fs.writeFileSync(`${wd}/classify.json`, JSON.stringify(r, null, 2));
+}

+ 4 - 2
references/s3-upload.md

@@ -4,8 +4,10 @@
 
 ```
 桶:   storage-s3-nkkj (华为云 OBS, cn-north-4)
-域名: 公网直接URL https://storage-s3-nkkj.obs.cn-north-4.myhuaweicloud.com/<key>
-      (s3.fmode.cn CNAME 生效后可换 https://s3.fmode.cn/<key>, 相对路径报告不受影响)
+对外分发域名(硬性): https://s3.fmode.cn/<key>
+      (s3.fmode.cn 反代已生效, 报告相对路径资源只有经它才能被浏览器正常加载;
+       OBS 原生域名 storage-s3-nkkj.obs.cn-north-4.myhuaweicloud.com 仅用于
+       上传/签名协议层, 严禁出现在发给用户的链接里)
 铁律: 只能写 user/<我的userid>/ 前缀; 他人前缀 = 越权
 ```
 

+ 24 - 19
scripts/build_report.py

@@ -315,25 +315,30 @@ def main():
 
     # --- LLM 逐段点评(失败回退启发式) ---
     llm_engine, llm_out = "规则启发式", {}
-    try:
-        seg_lines = "\n".join(
-            f"[{fmt_ts(s['bg'])}-{fmt_ts(s['ed'])}] {s.get('text','')}" for s in segs)
-        frame_lines = "\n".join(
-            f"{f['t_label']}: {f.get('caption','')}" for f in frames) or "(无)"
-        st = meta.get("stats") or {}
-        prompt = LLM_PROMPT.format(
-            dur=round(duration_s), digg=st.get("digg", "?"), cmt=st.get("comment", "?"),
-            coll=st.get("collect", "?"), share=st.get("share", "?"),
-            fulltext=fulltext[:1200], segment_lines=seg_lines, frame_lines=frame_lines)
-        raw, routed = call_llm(prompt)
-        llm_out = parse_llm_json(raw)
-        if llm_out.get("segments"):
-            llm_engine = f"{TEXT_MODEL}→{routed}"
-            print(f"[report] LLM 点评 {len(llm_out['segments'])} 段 ({llm_engine})")
-        else:
-            print("[report] LLM 输出无法解析, 回退规则启发式")
-    except Exception as ex:
-        print(f"[report] LLM 调用失败({ex}), 回退规则启发式")
+    manual = load_json(os.path.join(wd, "llm_out.json"))
+    if manual and manual.get("segments"):
+        llm_out, llm_engine = manual, "手动点评(llm_out.json)"
+        print(f"[report] 使用手动点评 {len(llm_out['segments'])} 段 (llm_out.json)")
+    else:
+        try:
+            seg_lines = "\n".join(
+                f"[{fmt_ts(s['bg'])}-{fmt_ts(s['ed'])}] {s.get('text','')}" for s in segs)
+            frame_lines = "\n".join(
+                f"{f['t_label']}: {f.get('caption','')}" for f in frames) or "(无)"
+            st = meta.get("stats") or {}
+            prompt = LLM_PROMPT.format(
+                dur=round(duration_s), digg=st.get("digg", "?"), cmt=st.get("comment", "?"),
+                coll=st.get("collect", "?"), share=st.get("share", "?"),
+                fulltext=fulltext[:1200], segment_lines=seg_lines, frame_lines=frame_lines)
+            raw, routed = call_llm(prompt)
+            llm_out = parse_llm_json(raw)
+            if llm_out.get("segments"):
+                llm_engine = f"{TEXT_MODEL}→{routed}"
+                print(f"[report] LLM 点评 {len(llm_out['segments'])} 段 ({llm_engine})")
+            else:
+                print("[report] LLM 输出无法解析, 回退规则启发式")
+        except Exception as ex:
+            print(f"[report] LLM 调用失败({ex}), 回退规则启发式")
 
     llm_segs = {int(s.get("i", i)): s for i, s in enumerate(llm_out.get("segments") or [])}
 

+ 26 - 2
scripts/common.py

@@ -351,10 +351,34 @@ def s3_put_file(path: str, key: str, content_type: str = None) -> str:
 
 
 def public_url(key: str) -> str:
-    """公开访问 URL。s3.fmode.cn CNAME 生效前用 OBS 原生域名。"""
+    """公开访问 URL。
+
+    规则(2026-08-30 用户确认, 硬性): 对外分发的链接 host 必须是 s3.fmode.cn,
+    不能用 OBS 原生域名(storage-s3-nkkj.obs.cn-north-4.myhuaweicloud.com) ——
+    报告里的相对路径资源只有经 s3.fmode.cn 反代才能被浏览器正常加载。
+    S3_PUBLIC_BASE 环境变量仍可覆盖(高级用法)。签名/上传仍走 OBS 原生 host。
+    """
     if os.environ.get("S3_PUBLIC_BASE"):
         return f"{os.environ['S3_PUBLIC_BASE'].rstrip('/')}/{key}"
-    return f"https://{S3_BUCKET}.{S3_HOST}/{key}"
+    return f"https://s3.fmode.cn/{key}"
+
+
+def verify_public_host(key: str = "") -> bool:
+    """启动自检: s3.fmode.cn 反代可达(200/302/403都算通, 404=桶无此对象但域名通)。
+    不可达时打印告警(链接规范仍不变, 交给人工处理 DNS)。"""
+    url = f"https://s3.fmode.cn/{key or S3_BUCKET.replace('-s3','')}"
+    try:
+        req = urllib.request.Request(url, method="HEAD",
+                                     headers={"User-Agent": "Mozilla/5.0"})
+        with urllib.request.urlopen(req, timeout=12) as r:
+            ok = r.status < 500
+    except urllib.error.HTTPError as e:
+        ok = e.code < 500
+    except Exception as e:
+        ok = False
+        print(f"[common] WARN s3.fmode.cn 不可达({e}), 公开链接仍按规范生成, 请检查 DNS/反代",
+              file=sys.stderr)
+    return ok
 
 
 def guess_content_type(path: str) -> str:

+ 3 - 2
scripts/publish.py

@@ -18,7 +18,7 @@ import sys
 import urllib.request
 
 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
-from common import get_userid, public_url, s3_put_file  # noqa: E402
+from common import get_userid, public_url, s3_put_file, verify_public_host  # noqa: E402
 
 MANIFEST_CT = {
     ".html": "text/html; charset=utf-8",
@@ -73,7 +73,8 @@ def main():
         urls.append((rel, url))
         print(f"[publish] ✓ {rel} ({os.path.getsize(local)/1024:.0f} KB)")
 
-    # 逐个 HTTP 校验(外部状态回读, 不只信 PUT 返回)
+    # 逐个 HTTP 校验(外部状态回读, 不只信 PUT 返回) + 公开域名自检
+    verify_public_host()
     fails = []
     for rel, url in urls:
         try:

+ 107 - 0
scripts/publish_via_obs.py

@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+"""publish_via_obs.py — publish.py 的跳板机备用通道。
+
+适用场景: /api/storage/credentials 与 /api/storage/upload 均 404(未部署)、
+容器无 CLOUD_SDK_AK/SK、无 obsutil 时 —— 借道 server.fmode.cn(云服务器)上的
+obsutil(自带 nkkj 主账套 AK/SK)上传到 storage-s3-nkkj 桶。
+
+用法: python3 publish_via_obs.py --workdir /tmp/lapian [--date 20260829]
+产物清单与 publish.py 完全一致, 上传后逐个 HEAD 校验, 写 workdir/publish.json。
+"""
+import argparse, datetime, json, os, sys, tarfile, time, urllib.request
+import paramiko
+
+CLOUD = {"host": "139.159.253.131", "user": "root", "password": "bofang666."}
+OBSUTIL = "/opt/obsutil/obsutil_linux_amd64_5.4.11/obsutil"
+BUCKET = "storage-s3-nkkj"
+ENDPOINT = "obs.cn-north-4.myhuaweicloud.com"
+# 对外链接 host 规范(2026-08-30 硬性): 必须用 s3.fmode.cn, 禁用 OBS 原生域名
+# (浏览器只有经 s3.fmode.cn 反代才能正常加载报告的相对路径资源)
+PUB = "https://s3.fmode.cn"
+
+CT = {".html": "text/html; charset=utf-8", ".json": "application/json",
+      ".mp4": "video/mp4", ".wav": "audio/wav", ".jpg": "image/jpeg"}
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--workdir", required=True)
+    ap.add_argument("--date", default=datetime.date.today().strftime("%Y%m%d"))
+    args = ap.parse_args()
+    wd = args.workdir
+    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+    from common import get_userid
+    userid = get_userid()
+    base = f"user/{userid}/report/lapian/douyin/{args.date}"
+
+    # 打包清单(与 publish.py 一致)
+    files = ["report.html", "video.mp4", "audio.wav", "transcript.json"]
+    files = [f for f in files if os.path.exists(f"{wd}/{f}")]
+    files += [f"frames/{f}" for f in sorted(os.listdir(f"{wd}/frames"))] if os.path.isdir(f"{wd}/frames") else []
+    pkg = "/tmp/lapian-pkg.tar.gz"
+    asset_map = []
+    for n in ("analysis.json", "comments.json", "meta.json", "llm_out.json"):
+        if os.path.exists(f"{wd}/{n}"):
+            asset_map.append((f"{wd}/{n}", f"assets/{n}"))
+    with tarfile.open(pkg, "w:gz") as tf:
+        for rel in files:
+            tf.add(f"{wd}/{rel}", arcname=rel)
+        for local, arc in asset_map:
+            tf.add(local, arcname=arc)
+    files += [arc for _, arc in asset_map]
+    print(f"[pkg] {len(files)} files, {os.path.getsize(pkg)/1048576:.1f} MB")
+
+    # SFTP(带重试, banner 抖动常见)
+    ssh = paramiko.SSHClient()
+    ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+    for attempt in range(4):
+        try:
+            ssh.connect(CLOUD["host"], 22, CLOUD["user"], CLOUD["password"],
+                        timeout=30, banner_timeout=30)
+            break
+        except Exception as ex:
+            print(f"[ssh] attempt {attempt+1} failed: {type(ex).__name__}, retry 15s")
+            time.sleep(15)
+    else:
+        sys.exit("[ssh] all retries failed")
+    sftp = ssh.open_sftp()
+    sftp.put(pkg, "/tmp/lapian-pkg.tar.gz")
+    sftp.close()
+
+    remote = f"""
+set -e
+rm -rf /tmp/lapian-pkg && mkdir -p /tmp/lapian-pkg
+tar -xzf /tmp/lapian-pkg.tar.gz -C /tmp/lapian-pkg
+cd /tmp/lapian-pkg
+for f in {' '.join(files)}; do
+  if {OBSUTIL} cp "$f" "obs://{BUCKET}/{base}/$f" -acl=public-read -e={ENDPOINT} -j=5 > /tmp/obs_last.log 2>&1; then
+    echo "UP OK $f"
+  else
+    echo "UP FAIL $f: $(tail -2 /tmp/obs_last.log)"
+  fi
+done"""
+    _, stdout, stderr = ssh.exec_command(remote, timeout=600)
+    out = stdout.read().decode(errors="replace")
+    print(out)
+    ssh.close()
+    fails = [l for l in out.splitlines() if "UP FAIL" in l]
+
+    # 公网校验
+    bad = []
+    for rel in files:
+        try:
+            req = urllib.request.Request(f"{PUB}/{base}/{rel}", method="HEAD")
+            with urllib.request.urlopen(req, timeout=25) as r:
+                if r.status != 200:
+                    bad.append((rel, r.status))
+        except Exception as ex:
+            bad.append((rel, str(ex)[:60]))
+    report_url = f"{PUB}/{base}/report.html"
+    print(f"\n[verify] {len(files)-len(bad)}/{len(files)} OK", f"fails={bad}" if bad else "ALL PASS")
+    json.dump({"report_url": report_url, "prefix": base, "fails": bad + fails},
+              open(f"{wd}/publish.json", "w"), ensure_ascii=False, indent=2)
+    print("REPORT URL:", report_url)
+
+
+if __name__ == "__main__":
+    main()