初始化提交:contentm_agent 工作区全量快照

内容分四块:
1、产品规划产出 —— MCN 短视频整合营销工作台的①段五份(1a 需求/1b 竞品/1c 画像/1d 策略/1e 场景)、②段两份(2a 功能/2b 布局)、③段界面(DESIGN.md 契约与令牌表 + mcn-workbench.html 原型 + 实测/会诊/审查三份 + 23 张闸门截图)。
2、开源竞品调研 —— 5 个内容工作台项目的取证原始件与 1b 系列分析文档。
3、参考资料 —— 竞品视频抽帧 1145 张 + 2 个源视频 + 功能点截图。
4、机制侧 —— 协作脚本与状态台账、工作区记忆日志、抽帧/OCR 脚本。

.gitignore 只排运行时日志、脚本备份副本与一次性探针输出,其余按原样入库。
This commit is contained in:
WorkBuddy committed 2026-10-08 08:13:02 +08:00
commit df56c2c137
1773 files changed
+205840

No files matched your search

@@ -0,0 +1,19 @@
# 操作规范(SOP)
> aigc-idea-impression 的操作规范目录。使用中涉及的高频/高风险操作,统一沉淀为可执行的 SOP,持续完善。
## 规范清单
| 规范 | 文件 | 适用范围 |
|------|------|---------|
| 浏览器访问 | [浏览器访问操作规范.md](浏览器访问操作规范.md) | 抖音/小红书/B站等网页抓取(browser-harness / agent-browser) |
| 获取抖音账号数据 | [获取抖音账号数据操作规范.md](获取抖音账号数据操作规范.md) | 抖音账号信息端到端获取(搜索→提取→解析→归档) |
| **视频抽帧与字幕** | [视频抽帧与字幕规范.md](视频抽帧与字幕规范.md) | 视频下载后抽帧(双因素:画面+字幕变化)+ 字幕转录(首尾帧+补帧) |
| 数据归档 | [数据归档规范.md](数据归档规范.md) | MCNSkill项目 数据归档(榜单/信息源/作品/视频) |
| 审计检查 | [审计检查清单.md](审计检查清单.md) | 技能结构/引用/知识库/红线 定期审计 |
## 使用规则
- 首次执行某类高风险操作前,先读对应 SOP
- 操作中发现的新经验,同步补充到对应 SOP 或「经验方法总结.md」
- SOP 与 POLICIES.md 冲突时以 POLICIES.md(红线)为准
@@ -0,0 +1,80 @@
# 审计检查清单
> aigc-idea-impression 技能审计检查框架。审计 SKILL.md + references + subskill 时逐项对照。
> 对齐 mcn-short-video 的「技能审计检查清单」(1-17 条),并补充总控层检查点。
---
## 总纲
**流程清晰 → 分类清晰 → 引用完整 → 红线执行 → 经验沉淀**
---
## 一、subskill 结构(总控层)
| # | 检查点 | 说明 |
|---|--------|------|
| 1 | 总控路由正确 | 顶层 SKILL.md 路由规则与实际 subskill 对应 |
| 2 | subskill 完整 | 三个子技能(brand-strategy/content-creation/content-publish)均在位,内部结构统一(SKILL.md + references + scripts + references-add) |
## 二、流程清晰
| # | 检查点 | 说明 |
|---|--------|------|
| 3 | 步骤顺序正确 | 各流程步骤无错乱 |
| 4 | 流程独立 | 不相关流程独立成文,不硬合并 |
## 三、知识库分类
| # | 检查点 | 说明 |
|---|--------|------|
| 5 | 分类边界清晰 | 知识库按主题/区域分域,每类职责单一 |
| 6 | 分类不重叠 | 同一知识只归属一个分类 |
| 7 | 内容独立 | 各分类可独立阅读 |
| 8 | 通用vs分类区分 | 通用知识与分类知识明确分层 |
## 四、引用完整性
| # | 检查点 | 说明 |
|---|--------|------|
| 9 | 引用路径正确 | 引用走对层级 |
| 10 | 文件存在性 | 提取全部 references/scripts/subskill 引用逐一核对存在(死链检测,排除 `(url)` 模板占位符) |
| 11 | 上游→下游不断裂 | 数据流引用完整 |
## 五、红线执行(POLICIES.md)
| # | 检查点 | 说明 |
|---|--------|------|
| 12 | 凭证无硬编码 | 全库无 API Key/Cookie/Token 硬编码 |
| 13 | 浏览器边界 | 无绕过风控/登录的代码或指令 |
| 14 | 数据边界 | 无账号写操作;浏览器优先规则落实 |
| 15 | 投放确认 | 发布类流程含强制确认门(Gate-3) |
| 16 | 版权合规 | 无搬运/侵权内容指导 |
## 六、经验沉淀
| # | 检查点 | 说明 |
|---|--------|------|
| 17 | 问题沉淀及时 | 近期问题已追加到「经验方法总结.md」 |
| 18 | 红线/规范同步 | 新红线已升级 POLICIES.md,新 SOP 已入操作规范 |
## 七、抽帧 / 字幕产出校验(视频类任务)
| # | 检查点 | 说明 |
|---|--------|------|
| 19 | 字幕区 scene 0.1 在场 | 帧集须来自 `crop + select='gt(scene,0.1)'`(核对 `frame_times.json` 来源标签含 sub01);无则字幕漏帧 |
| 20 | 双因素+首尾+补帧齐全 | 帧集须含 scene 0.3 / scene 0.05 / 首尾帧 / >5s 补帧,缺任一即不合规 |
| 21 | 字幕区 crop 按分辨率 | 核对 crop 值与视频分辨率匹配(1080p→162 / 720p→108 / 竖屏→240),且先抽一帧确认字幕位置 |
| 22 | 字幕逐帧 OCR | 产出须基于逐帧 OCR(非仅代表帧),时间线表(时间\|台词\|帧文件)完整 |
| 23 | CRLF 防护 | 抽帧脚本写时间戳列表须 `newline='\n'`,否则 `-ss` 全批失败 |
---
## 使用方法
1. 按 1→23 顺序逐条检查,不跳检
2. 死链检测可用脚本:正则提取 Markdown 链接目标(相对路径形式)→ `os.path.exists` 校验
3. 问题分级:P0(红线/断链)/ P1(不一致/冗余/分类越界)
4. 修复逐条进行:读一条→改一条→验证一条
5. 沉淀:发现的问题追加到「经验方法总结.md」
@@ -0,0 +1,67 @@
# 数据归档规范(SOP)
> aigc-idea-impression 数据获取后的归档规则。**数据根目录:`D:\MCNSkill项目\`**(2026-08-30 由 `RedFox数据` 迁移更名;旧路径已废弃)。
---
## 一、目录规则(按数据类型分目录,作品/视频按账号名分)
| 数据类型 | 目录 | 分目录粒度 | 示例 |
|---------|------|-----------|------|
| **榜单数据**(日榜、七日飙升榜等) | `MCNSkill项目\榜单数据\YYYY-MM\` | 按月 | `榜单数据\2026-08\剧情赛道热榜账号TOP10_2026-08-29.json` |
| **信息源数据**(AI信息源、短剧信息源等) | `MCNSkill项目\信息源数据\YYYY-MM\` | 按月 | `信息源数据\2026-08\...json` |
| **作品数据**(单作品爬取、账号诊断等) | `MCNSkill项目\作品数据\{账号名称}\` | **按账号**(无日期层) | `作品数据\王墨绫子\王墨绫子_长视频筛选_2026-08-30.json` |
| **视频数据**(下载的视频 + 抽帧/音频/报告等) | `MCNSkill项目\作品数据\{账号名称}\{视频标题清洗后}_{YYYY-MM-DD}\` | 按账号 + 按视频标题+发布时间 | `作品数据\王墨绫子\不是一见钟情是第一次见面穿了包臀裙_2026-08-27\video2.mp4` |
## 二、视频数据按标题文件夹规则(核心)
- **每个视频一个文件夹**,文件夹名 = **`{视频标题清洗后}_{发布时间YYYY-MM-DD}`**(标题去所有空格 + Windows 非法字符 `\ / : * ? " < > |` + 常见标点符号 `#,。?!、,,.~!@$%^&()[]{}` 等;发布时间取视频实际发布时间,如 2026-08-27)
- 层级:`作品数据\{账号名称}\{视频标题清洗后}_{YYYY-MM-DD}\`(不用时间文件夹)
- 文件夹内放该视频的全部数据:`{视频}.mp4`、`audio.wav`、`{直链}.txt`、`frames/`(抽帧)、`理解报告_*.md` 等
- 账号级/中间数据(如长视频筛选 JSON、Fiber 原始提取、视频列表 Excel)放**账号层**(`作品数据\{账号名称}\`,不放视频文件夹)
- 示例:`不是一见钟情 是第一次见面穿了包臀裙`(2026-08-27 发布)→ `作品数据\王墨绫子\不是一见钟情是第一次见面穿了包臀裙_2026-08-27\`
- **视频列表 Excel**:账号层保存 `{账号名称}_视频列表_{YYYY-MM-DD}.xlsx`(13 列:序号/视频ID/视频标题/视频地址/点赞数/点赞显示/评论数/分享数/收藏数/播放量/视频时长/发布时间/标签),供后续使用与更新(参考 mcn-dou-analysis 短视频表格规范)
## 三、中间产物管理(防止账号层膨胀,2026-08-30 实测教训)
**账号层只保留:数据源(如 fiber_dom_merged.json、redfox_worklist_raw.json)+ 最终交付(Excel/报告)+ 视频文件夹**。
- **中间产物用后即清**:每次提取/审计的临时文件(fiber_extract_raw / fiber_full_raw / fiber_audit / fiber_capability 等)**只保留最新一份**(供追溯),旧版立即移入 `_中间产物_待清理/` 或删除,禁止逐轮堆积
- **cookies 等敏感临时文件**:用后立即清理(不过夜),禁止长期留存
- **mp4/音频/帧**:必须进对应视频文件夹,禁止放账号层
- 清理时机:每次任务完成归档时顺手检查账号层,超 5 个非交付文件即整理
## 三、命名规则
- 文件名必须含**实际数据日期**(不是获取日期),如 `_2026-08-29.json`
- 命名模式:`{类型}_{描述}_{YYYY-MM-DD}.json`
## 三、格式规则
- **统一 JSON**(`.json`),UTF-8 编码,`ensure_ascii=False`
- **保留完整结构化字段**(原始 API 响应的关键字段原样保存),便于后续入库
- 榜单类:外层含 `{date, category, count, data: [...]}`;账号类:含 `{date, source, account: {...}}`
## 四、数据来源标注
- 归档 JSON 中标注 `source`(如 `红狐API-likesRank` / `browser-harness-抖音网页版`),区分数据来源
- 浏览器获取的数据按红线规则优先使用;第三方 API 兜底时注明
## 五、操作步骤
```python
import os, json, re
# 榜单/信息源:按分类按月
base = r"D:\MCNSkill项目\{榜单数据|信息源数据}\{YYYY-MM}"
# 作品/视频数据:按账号名(无日期层)
base = r"D:\MCNSkill项目\作品数据\{账号名称}"
def clean_title(t):
t = re.sub(r'\s+', '', t)
t = re.sub(r'[\\/:*?"<>|#,。?!、,,.·~~!@$%^&()()\[\]【】{}]', '', t)
return t
if 是视频: base = os.path.join(base, clean_title(视频标题))
os.makedirs(base, exist_ok=True)
fp = os.path.join(base, f"{类型}_{描述}_{YYYY-MM-DD}.json")
with open(fp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
```
@@ -0,0 +1,84 @@
# 浏览器访问操作规范(SOP)
> 适用于 aigc-idea-impression 各 subskill 中的网页抓取场景(抖音账号/视频、小红书/B站内容等)。
> 工具:**browser-harness**(抖音专项,推荐)+ **agent-browser**(通用兜底)。本机实测环境:browser-harness 0.1.8,`~/.local/bin/browser-harness.exe`。
---
## 一、环境准备(每次任务前【必做】)
### 1. 清除代理环境变量(最重要,漏掉必踩坑)
本机 Shadowsocks 的 Privoxy 组件监听 10800,会劫持 127.0.0.1 的 CDP 请求(报 "Internal Privoxy Error" 或连接失败):
```bash
unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY
export NO_PROXY="127.0.0.1,localhost,::1"
```
### 2. 连接方式(按顺序尝试)
| 方式 | 操作 | 说明 |
|------|------|------|
| A. 自动(推荐) | 直接运行 browser-harness(daemon 自动启动连接浏览器) | 最简单;可能触发 Chrome 授权弹窗(见二) |
| B. 定向连接 | `export BU_CDP_URL="http://127.0.0.1:<端口>"` 后运行 | 端口:9222(用户 Chrome)/ 9223(独立实例) |
| C. 独立实例 | `chrome.exe --remote-debugging-port=9223 --user-data-dir=<固定目录> about:blank` | 与用户浏览器隔离 |
### 3. 环境检查
```bash
browser-harness doctor # 确认 工具/daemon/Chrome 三要素;daemon 失败用 browser-harness --reload
```
---
## 二、Chrome 远程调试授权(关键机制)
- Chrome 151+ 首次连接会弹 **「Allow remote debugging?」** 授权框,点击「允许」后才可用(记住后不再弹)
- 弹窗可能在后台:看不到时打开 `chrome://inspect/#remote-debugging` 勾选启用
- 连接前探测:`curl http://127.0.0.1:<端口>/json/version` 返回含 "Browser" 的 JSON = 可用
---
## 三、标签页复用规则(避免窗口/标签堆积)
1. 先检查已有标签页(`cdp("Target.getTargets", flatten=True)`),**有则直接导航复用**,不新建
2. 没有才 `new_tab(...)`
3. **用完不关闭**标签页(留给后续任务复用)
---
## 四、调用示例
```bash
BH="$HOME/.local/bin/browser-harness.exe"
# 打开抖音搜索账号(type=user 限定用户结果)
"$BH" <<'PY'
import time
goto_url("https://www.douyin.com/search/{昵称URL编码}?type=user")
time.sleep(6)
r = js("document.body.innerText")
text = r.get("value", "") if isinstance(r, dict) else str(r)
print(text)
PY
```
---
## 五、适度访问(红线)
- **控制频率**:搜索/详情页访问间隔拉长,不连续高频请求
- 视频列表懒加载:滚动 2~3 次收集足够数据即停
- 未登录态功能受限:如实告知(如作品列表需登录),不假装已获取
---
## 六、失败排查顺序
```
1. doctor(工具/daemon/Chrome 三要素)
2. 端口探测 9222/9223/9224 的 /json/version(IPv4 + [::1])
3. 代理检查:确认 HTTP(S)_PROXY 已清
4. daemon 异常:browser-harness --reload
5. 仍失败 → 提示用户 Chrome 点「允许」+ chrome://inspect 检查
```
@@ -0,0 +1,129 @@
# 用 browser-harness 访问 AI 工具(ChatGPT 等)的提问方法
> 适用场景:需要让 ChatGPT / Claude 之类**网页版 AI 工具**参与工作(求修改建议、核对方案、做结构评审)时的通道与提问规范。
> 本机实测环境:browser-harness 0.1.8(`~/.local/bin/browser-harness.exe`)、Chrome 154、独立 profile `E:/ProgramData/.workbuddy/tmp/bu-chatgpt-profile`。
> 建立时间:2026-10-07(用户定案)。
---
## 一、通道:怎么把浏览器跑起来
### 1. 🔴 浏览器要由**常驻后台任务**开着
本机的执行沙箱会在**父进程退出时连带杀掉子进程**。实测对照:
| 起法 | 结果 |
|---|---|
| 普通(同步)调用里起 Chrome | 端口 1 秒内就绪,但**调用一返回、Chrome 立刻死**(`curl` 随即 `WinError 10061`) |
| **后台任务里起,且该任务一直不退出**(起完就守着端口) | ✅ **全程常驻**,后续可以反复连 |
| 后台任务里起,但任务跑完就退出 | 任务一结束,Chrome 一起没 |
⇒ 正确姿势:**先起一个常驻任务专门把浏览器开着**(起 Chrome → 循环守着端口 → 掉了写日志),之后每次操作**只连不起**。
⛔ 别让每次调用各自起一遍浏览器 —— 父进程一退就全没了;而且多个实例会抢同一个 profile,新起的会被旧实例「接管」后随即退出。
### 2. 起独立实例,⛔ 不附着用户日常 Chrome
```bash
chrome.exe --remote-debugging-port=9223 \
--user-data-dir=E:/ProgramData/.workbuddy/tmp/bu-chatgpt-profile \
--proxy-server="socks5://127.0.0.1:10800" about:blank
```
- 端口用 **9223**(用户日常 Chrome 是 9222,附着它会弹「允许远程调试?」去动用户自己的浏览器)。
- `--user-data-dir` 固定复用 ⇒ **登录态保留**(该 profile 已登录 ChatGPT 免费版)。
### 3. `BU_CDP_URL` **必须带 scheme**
`http://127.0.0.1:9223` ✅ / `127.0.0.1:9223` ❌(直接报 `unknown url type`)。
### 4. 必须清掉沙箱代理变量
`HTTP_PROXY` / `HTTPS_PROXY` 会劫持本地 CDP 请求 ⇒ `HTTP Error 502: Bad Gateway`。
```bash
env -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy \
NO_PROXY=127.0.0.1,localhost BU_CDP_URL=http://127.0.0.1:9223 browser-harness
```
Python 里用 `subprocess` 调时,同样要在 `env` 里 `pop` 掉这四个变量。
### 5. 可执行体是 `.exe`
`~/.local/bin/browser-harness.exe`。Python `subprocess.run(["browser-harness", ...])` 在 Windows 上可能找不到 ⇒ 写全名。
---
## 二、提问方法(重点)
### 1. 只说明「意图和目标 + 完整上下文」,少写控制指令
2026-10-07 用户定案:**和 AI 沟通,说清意图和目标、给出完整上下文就行,不用写太多控制指令。**
- ✅ 写清四件事:**背景是什么 → 目标是什么 → 我做了什么 → 一句明确的提问**。
- ⛔ 不写「别只夸」「逐条说」「必须回答 X 个问题」「你只能……」「按下面格式回答」这类操纵性/限制性指令 —— 会挤占上下文,还会诱导回答偏向。
### 2. 它看不到你的本机路径
⛔ 不要把「见 `references/xxx.md`」「文件在 `E:/...`」当作交代内容 —— AI 读不到本机。
两条可行做法(任选):
| 做法 | 说明 |
|---|---|
| **全文内联** | 把要评审的内容**逐字贴进消息正文**(已验证可行:5,078 字正常送达) |
| **上传文件** | 用 `input[type=file]` + `DOM.setFileInputFiles` 把文件作为附件传上去,正文只写简短提问 |
### 3. 一轮一个主题,末尾一句明确提问
提问句要短、要具体,例如「你看看这版还有哪里需要优化」。
### 4. ✅ 判断「消息有没有真发出去」的硬证据 = **输入框被清空**
血的教训(2026-10-07 实测踩到):**页尾出现我的问题文本 ≠ 已发出** —— 因为输入框(`div.ProseMirror`)本身就在 `main` 元素里,它的内容会出现在 `innerText` 的**尾部**,看起来就像已经发出去的提问。
⇒ 判据只有一条:**发送动作之后回读输入框,长度归零**。
---
## 三、页面操作细节(ChatGPT 当前 Web UI · 实测)
| 项 | 当前可用写法 |
|---|---|
| 输入框 | `div.ProseMirror[role="textbox"]` ⚠️ 旧写法 `#prompt-textarea` **已不存在** |
| 发送按钮 | `#composer-submit-button` → `button[data-testid="send-button"]` → `button[aria-label="发送"]`(依序兜底) |
| 取正文 | `(document.querySelector('main')||document.body).innerText` |
| 上传文件 | `input[type="file"]` + `cdp("DOM.setFileInputFiles", files=[路径], nodeId=...)` |
| 消息切分锚点 | `你说:` / `ChatGPT 说:` / `ChatGPT 可能会出错` |
⚠️ **三个易误判点**:
1. **一页可能有多个 `ChatGPT 说:`**(历史轮次)⇒ 取正文必须用 **`rfind` 取最后一个**,`find` 会切到上一轮的回复。
2. **「ChatGPT 可能会出错」不是完稿信号** —— 它一直在页面上。完稿判据 = **正文长度稳定**(连续两次采样不变)。
3. **「思考」是输入框工具栏的按钮文案**,不是「正在生成」状态。
---
## 四、脚本写法(避坑)
- ⛔ **不要在 Python 里拼 heredoc + 转义引号喂给 harness** —— 嵌套转义必然出 `SyntaxError`(实测踩到)。
- ✅ 把每一步 harness 脚本**写成独立 `.py` 文件**(`step_open.py` / `step_send.py` / `step_probe.py` / `step_dump.py`),驱动脚本读文件后经 stdin 喂给 harness。
- ✅ 驱动全程**写日志落盘**(`run_xxx.log`),每步打 rc 与关键读数,失败时能直接定位到是哪一步。
---
## 五、失败排查顺序
```
1. 端口探活:curl --noproxy '*' -m 5 http://127.0.0.1:9223/json/version
2. 进程在不在(tasklist):不在 ⇒ 十有八九是父进程退了(回到「一.1」用后台任务)
3. 代理变量是否清干净(一.4)
4. browser-harness --doctor
5. 仍失败 ⇒ 检查 Chrome 是否弹「允许远程调试」(chrome://inspect/#remote-debugging)
```
---
## 六、变更历史
- **2026-10-07** 建立。来源:本次用 browser-harness 调 ChatGPT 核对 1b 竞品分析方法论修改效果的全过程实测。
@@ -0,0 +1,324 @@
# 获取抖音账号数据 操作规范(SOP)
> 端到端流程:从「账号昵称」到「归档 JSON」。基于 2026-08-30 实测(雨菲啊账号获取)沉淀。
> 工具:browser-harness(`~/.local/bin/browser-harness.exe`)。
> 通用浏览器准备步骤见 [浏览器访问操作规范.md](浏览器访问操作规范.md)(本文件聚焦抖音账号获取全流程)。
## 〇、获取方式原则(红线,先读)
**必须模拟真实用户操作**——具体指**模拟用户输入和鼠标点击**:
| 能力 | 命令 | 说明 |
|------|------|------|
| 键盘输入 | `fill_input(selector, text)` / `type_text(text)` | 模拟键入(如搜索框输入关键词) |
| 按键 | `press_key(key)` | 模拟回车/ESC 等按键 |
| 鼠标点击 | `click_at_xy(x, y)` | 模拟点击(如点搜索按钮、点用户卡片) |
| 滚动 | `scroll(x, y, dy)` | 模拟滚轮滚动浏览 |
| 读取 | `js(...)` / `page_info()` | **仅用于读取数据 / 定位元素坐标**,不代替用户行为 |
**禁止**:用 `goto_url` URL 直达代替搜索/点击;禁止运行内置脚本/第三方 API 代替浏览器行为。`goto_url` 仅用于打开站点首页。
- 判定速查:能浏览器模拟 → 必须走浏览器(输入+点击+滚动);浏览器被验证码/登录墙拦截 → 才可降级第三方并注明
- 行为节奏:等待渲染(6-7s)、按需滚动(2-3 次)、控制频率
### 🚨 风控规避红线(2026-08-30 实测教训,先读)
**触发风控识别 → 立即停止操作,禁止继续**。风控窗口期继续操作会加重风控(验证页升级、内容区封锁、预览节流)。
| 风控信号 | 表现 | 处理 |
|---------|------|------|
| 预览节流 | 批量/连续 hover 卡片时长全部读不到 | **立即停止 hover 类操作** |
| 验证页 | 页面 title 变成 emoji(如 🐴)、正文只剩导航+页脚 | **立即停止**,关闭相关页面标签 |
| 内容区封锁 | 主页/搜索页只有导航栏,账号信息与作品列表不渲染 | **立即停止**,禁止刷新重试 |
| CDP timed out | browser-harness 连续超时 | 停止操作,`doctor` 确认 daemon 后再动 |
**恢复流程**:
1. **冷却**:停止一切抖音自动化操作,至少 **15-30 分钟**(浏览器页面保持闲置,不刷新不操作)
2. 冷却后先**手动正常访问一次**(打开抖音首页浏览),确认无验证页再恢复自动化
3. 恢复后**严格按用户节奏**:一次只 hover/点击一个元素,间隔自然(3-5s+),不批量扫描、不固定节奏
**操作节奏铁律**(防再触发):
- 一次只处理 1 个目标(如只 hover 一个卡片),读完再处理下一个
- 不做 for 循环连续 hover/点击(机器扫描特征)
- 每次操作间有"浏览停顿",不机械匀速
---
## 一、触发场景
用户要求获取某抖音账号数据(粉丝/获赞/简介/抖音号等)时执行。如「获取 XXX 账号数据」「查一下 XXX 账号」。
## 二、环境准备(每次必做)
```bash
# 1. 清代理(Privoxy 10800 劫持 CDP)
unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY
export NO_PROXY="127.0.0.1,localhost,::1"
# 2. doctor 检查三要素
browser-harness doctor
# 3. CDP 授权:Chrome 弹「Allow remote debugging?」→ 用户点允许
# (弹窗在后台时:chrome://inspect/#remote-debugging 勾选启用)
```
## 三、执行流程
### 步骤 1:打开抖音并搜索账号(模拟用户输入 + 点击)
```bash
BH="$HOME/.local/bin/browser-harness.exe"
"$BH" <<'PY'
import time
# ① 打开抖音首页(仅导航入口)
goto_url("https://www.douyin.com")
time.sleep(6)
# ② 模拟用户输入:点击搜索框 → 键入关键词 → 回车
# 先定位搜索框坐标(js 辅助定位,不代替行为)
r = js("""
(() => {
const els = Array.from(document.querySelectorAll('input'));
const inp = els.find(e => (e.placeholder||'').includes('搜索'));
if (!inp) return null;
const b = inp.getBoundingClientRect();
return {x: b.x + b.width/2, y: b.y + b.height/2};
})()
""")
pos = r.get("value", None) if isinstance(r, dict) else r
if pos:
click_at_xy(pos["x"], pos["y"]) # 点击搜索框(模拟鼠标点击)
time.sleep(1)
type_text("{昵称}") # 键入账号名(模拟键盘输入)
time.sleep(1)
press_key("Enter") # 回车搜索
time.sleep(6)
PY
```
> 备选:直接 `goto_url("https://www.douyin.com/search/{昵称URL编码}?type=user")` 定位到搜索页(导航式,非严格模拟用户,仅在交互定位困难时使用,且随后用点击进主页补齐)。
### 步骤 2:提取页面文本
```bash
"$BH" <<'PY'
r = js("document.body.innerText")
text = r.get("value", "") if isinstance(r, dict) else str(r)
idx = text.find("{昵称}")
seg = text[max(0,idx-100):idx+400]
print(seg)
PY
```
### 步骤 3:结构化解析(正则)
```
抖音号: 6301602506217
53.7万获赞 68.6万粉丝 ← 实际显示粘连:抖音号(13位) + 获赞 + 粉丝
简介:用户卡片下方文字段
```
```python
import re
dyid = re.search(r"抖音号[::]\s*([0-9]+)", seg)
fans = re.search(r"([\d.]+万?)粉丝", seg)
likes = re.search(r"([\d.]+万?)获赞", seg)
# 简介 = 卡片区域内的独立文本行(抖音号行之后的非数字行)
```
> ⚠️ 实测坑:搜索结果卡片中「抖音号/获赞/粉丝」粘连显示(如 `630160250621753.7万获赞68.6万粉丝`)——抖音号为 13 位数字,先切出抖音号再解析获赞/粉丝。
> ⚠️ 搜索卡片数据是**近期数据**(获赞可能仅为片段),**必须进主页**拿完整累计数据(见步骤 4)。
### 步骤 4:进入账号主页(模拟点击用户卡片)
搜索卡片仅公开片段,**进入主页**才能拿到完整数据(累计获赞/作品数/关注/IP属地/简介全文/置顶作品)。
```bash
# 模拟用户点击:定位目标用户卡片坐标 → 点击进入主页
"$BH" <<'PY'
import time
r = js("""
(() => {
// 找搜索结果中"目标昵称"的用户卡片可点击区域
const all = Array.from(document.querySelectorAll('a[href*="/user/MS4w"]'));
const cards = all.filter(a => (a.innerText||'').includes('{昵称}') || true);
const hrefs = [...new Set(all.map(a => a.getAttribute('href').split('?')[0]))];
// 返回第一个真实用户卡片中心坐标
const t = all[0]; if (!t) return null;
const b = t.getBoundingClientRect();
return {x: b.x + b.width/2, y: b.y + b.height/2, url: 'https:' + hrefs[0]};
})()
""")
info = r.get("value", None) if isinstance(r, dict) else r
if info:
click_at_xy(info["x"], info["y"]) # 模拟鼠标点击卡片 → 进入主页
time.sleep(7)
PY
# 提取主页文本(关注/粉丝/获赞/抖音号/IP属地/性别/简介/作品数/置顶作品)
```
> ⚠️ 实测坑 1:卡片 `href` 为协议相对 `//www.douyin.com/user/MS4w...`;若用导航兜底,用 `"https:" + href`,**不要**再拼域名。
> ⚠️ 实测坑 2:搜索页需等待渲染(6-7s);当前不在搜索页需先回到搜索页。
> ⚠️ 实测坑 3:点击式进入主页依赖卡片坐标,页面布局变化时坐标会偏移——点击后校验 `page_info()` 是否为主页(URL 含 `/user/`),失败则退回导航式兜底。
### 步骤 5:React Fiber 提取作品数据(⭐ 首选方法,mcn-dou-analysis 同款,避风控)
**不依赖 hover/网络层/全局变量**——抖音主页作品卡片的数据在 React 组件树(Fiber)的 `awemeInfo` 对象里,遍历 `a[href*="/video/"]` 元素向上查找 Fiber 即可一次性提取全部字段(含时长秒数),**无 hover、不触发预览节流**。2026-08-30 实测成功(王墨绫子 56 条,时长与 ffprobe 一致)。
```js
// 提取所有卡片:id|标题|时长(毫秒)
(() => {
const cards = Array.from(document.querySelectorAll('a[href*="/video/"]'));
const seen = new Set(), rows = [];
for (const a of cards) {
const href = a.getAttribute('href')||'';
const id = (href.match(/\/video\/(\d+)/)||[])[1];
if (!id || seen.has(id)) continue;
seen.add(id);
let fiber = null;
for (const k of Object.keys(a)) {
if (k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance')) { fiber = a[k]; break; }
}
if (!fiber) continue;
let node = fiber, info = null, depth = 0;
while (node && depth < 30) {
const mp = node.memoizedProps;
if (mp) {
for (const key of ['awemeInfo','data','item','aweme']) {
const v = mp[key];
if (v && typeof v === 'object' && (v.aweme_id || v.video)) { info = v; break; }
}
}
if (info) break;
node = node.return; depth++;
}
if (info) {
rows.push([id, (info.desc||'').split('\n')[0].slice(0,40), info.video ? info.video.duration : null].join('|'));
}
}
return rows.join('\n'); // ⚠️ 返回值有长度限制,用 slice(START, START+8) 分页取
})()
```
要点:
- **duration 是毫秒**(如 9400 = 9.4s,筛 ≥60s 用 `duration >= 60000`)
- js 返回值有截断限制(约 160 字符),**分页取**:`rows.slice(START, START+8).join('\n')` 循环取
- Fiber 提取不到的卡片(虚拟化差异)用 href 的 aweme_id 兜底
- 滚动加载配合:先滚动 `.route-sc`/`route-scroll-container` 容器(非 window)触发懒加载,随机间隔 1~3s,2~3 次即可
### 步骤 6:作品卡片 hover 识别时长 + 捕获视频直链(备选,慎用)
主页作品卡片**平时不显示时长**(时长角标是 hover 才渲染的视觉层),但:
1. **hover 卡片 → 卡片显示预览进度 `当前秒/总时长`**(如 `00:02/00:09`),即视频时长(总时长可靠)
2. **hover 同时触发预览视频加载 → 网络层直接捕获 douyinvod 直链** → curl 即可下载,无需进详情页、无需 yt-dlp/cookies
```bash
# ① 模拟鼠标悬停(hover)卡片 → 时长即现
"$BH" <<'PY'
import time
# 定位目标视频卡片坐标(js 辅助定位)
r = js("""
(() => {
const a = document.querySelector('a[href*="{视频ID}"]');
if (!a) return [0,0];
const b = a.getBoundingClientRect();
return [Math.round(b.x + b.width/2), Math.round(b.y + b.height/2)];
})()
""")
v = r.get("value", [0,0]) if isinstance(r, dict) else r
if v != [0,0]:
# 模拟鼠标移动(hover)——CDP Input.mouseMoved 即用户悬停
cdp("Input.dispatchMouseEvent", type="mouseMoved", x=v[0], y=v[1])
time.sleep(2)
r2 = js("(function(){var a=document.querySelector('a[href*=\"{视频ID}\"]'); return a?a.innerText.replace(/\\n+/g,'|'):'';})()")
print(r2.get("value","") if isinstance(r2,dict) else r2)
# 输出如:15.2万|00:02|/|00:09|不是一见钟情... → 总时长 00:09
PY
# ② 捕获 hover 预览触发的视频直链(douyinvod CDN)
"$BH" <<'PY'
cdp("Network.enable")
cdp("Input.dispatchMouseEvent", type="mouseMoved", x={x}, y={y}) # 再次 hover
time.sleep(3)
evs = drain_events()
urls = []
for e in evs:
try:
if e.get("method") == "Network.requestWillBeSent":
u = e.get("params",{}).get("request",{}).get("url","")
if "douyinvod" in u: urls.append(u)
except Exception: pass
uniq = list(dict.fromkeys(urls))
# 保存最后一个(通常为完整视频流)
open("video_url.txt","w").write(uniq[-1])
PY
# ③ curl 下载(带 Referer + UA)
curl -sL -o video.mp4 -H "Referer: https://www.douyin.com/" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36" \
"$(cat video_url.txt)" --max-time 90
# ④ ffprobe 验证:时长 + 分辨率(判断真视频 vs 图集预览)
ffprobe -v error -show_entries format=duration -show_entries stream=codec_type,width,height -of default=noprint_wrappers=1 video.mp4
```
> ⚠️ **图集 vs 真视频判定**(实测坑):
> - **列表层(hover 即判型,最快)**:hover 卡片显示预览进度 `当前秒/总时长` → **真视频**;hover 后无时长格式(只有赞+标题)→ **图集**。实测:真视频《不是一见钟情》hover 显示 `00:02/00:09`;图集《另外,你能来总裁吗?》hover 后仅 `3.8万|标题`,无 MM:SS。
> - **下载层(ffprobe 三要素)**:**图集预览**:时长短(约 2-3s)、**分辨率 720×960**、无有效音频 → 内容为静态图轮播;**真视频**:分辨率 1080P/2K+ 竖屏(如 1440×2560)、有 aac 音频流、时长与 hover 显示一致
> - 判定顺序:先 hover 判型(列表层直接筛掉图集),下载后再用 ffprobe 三要素复核
> - hover 时长仅预览加载瞬间显示,预览播完/移开鼠标即消失——提取要在 hover 后 1-3 秒内完成
### 步骤 7:归档(作品数据,按账号名,无日期层)
```
# 账号数据:账号层
D:\MCNSkill项目\作品数据\{账号名称}\{昵称}_账号数据_{YYYY-MM-DD}.json
# 视频数据:账号层下按视频标题清洗后建文件夹(规则见 数据归档规范.md)
D:\MCNSkill项目\作品数据\{账号名称}\{视频标题清洗后}\video.mp4
```
```python
{
"date": "YYYY-MM-DD",
"source": "browser-harness 抖音网页版",
"account": {
"name": "...", "douyinId": "...", "followers": "...", "totalLikes": "...",
"bio": "...",
"relatedAccounts": [...] # 搜索发现的相关账号(如日常号)
}
}
```
## 四、边界与限制
| 场景 | 处理 |
|------|------|
| 未登录态 | 搜索卡片仅公开信息;**主页可获取完整累计数据**(作品数/累计获赞/置顶作品) |
| 搜索不到账号 | 换昵称变体(如主号/日常号)、或检查是否同名干扰 |
| 触发验证码/限流 | 停止操作,间隔后重试;不绕过 |
| 需完整作品列表 | 主页滚动加载 2~3 次收集,适度访问 |
## 五、输出模板(对用户汇报)
| 字段 | 值 |
|------|-----|
| 昵称 | ... |
| 抖音号 | ... |
| 粉丝 | ... |
| 累计获赞 | ... |
| 简介 | ... |
| 关联账号 | ... |
## 六、经验沉淀关联
- 浏览器优先红线 → [POLICIES.md](../../POLICIES.md) P0-3
- 首次教训记录 → [经验方法总结.md](../../经验方法总结.md) #001
### 步骤 8:生成视频列表 Excel(账号层,参考 mcn-dou-analysis)
- 账号层保存 `{账号名称}_视频列表_{YYYY-MM-DD}.xlsx`,13 列:序号 | 视频ID | 视频标题 | 视频地址 | 点赞数 | 点赞(显示) | 评论数 | 分享数 | 收藏数 | 播放量 | 视频时长(秒) | 发布时间 | 标签
- 数据来源:**Fiber 提取时长**(id/标题/时长毫秒)+ **红狐 workList 补统计**(likeCount/commentCount/shareCount/publishTime,按 url 中视频 ID 精确匹配)
- 排序:按发布时间倒序;收藏数/播放量无数据时留空
- 后续更新:重新 Fiber 提取 + 红狐 workList 合并,覆盖更新同名 xlsx
@@ -0,0 +1,123 @@
# 视频抽帧与字幕规范(SOP)
> 抖音等平台视频下载后的抽帧与字幕提取标准流程。基于 2026-08-30 实测(《当队伍里有两只贪吃的猪》70.3s)沉淀。
> 应用场景:视频理解、画面反推提示词、字幕转录、分镜分析。
---
## ⚠️ 核心铁律(任何抽帧/字幕任务执行前必读,违反即漏帧 / 全批失败)
> 本规范结论**必须逐条执行,不是参考建议**。历史上已两次因"未按此执行"导致字幕大面积丢失(教训 #006、#010)。执行前请逐项勾选:
1. **双因素 + 首尾 + 补帧,缺一不可**:① 画面 `scene 0.3` ② **字幕区 `scene 0.1`(漏这条 = 画面静止但字幕换句的帧全丢)** ③ `scene 0.05` 补漏 ④ 首尾帧 ⑤ >5s 长间隔补中点。
2. **字幕区必须 crop 后再 scene 检测**,crop 按实际分辨率底部 15%:1080p 横屏 `crop=1920:162:0:918`;720p `crop=1280:108:0:612`;竖屏 720×960 `crop=720:240:0:720`。**先抽一帧确认字幕位置再 crop**。
3. **字幕 OCR 必须逐帧**:对每帧字幕区都做 OCR,再按时间滚动去重(相似度 ≥0.80 合并保留最长),**禁止只对每个聚类「代表帧」OCR**(代表帧模糊/过渡/fade → 整句丢失)。
4. **CRLF 防护**:Python/Windows 生成时间戳列表须 `newline='\n'`,否则行尾带 `\r` → ffmpeg `-ss` 报 Invalid duration 全批失败。
5. **禁 `select='eq(t,...)'` 精确时间匹配抽帧**(浮点误差命中率极低),一律用 `-ss` 逐点抽。
6. **反推 ≠ 规则**:禁止从旧产物(`scene_*.txt` / `frames_combined` / `kf_*`)反推流程当规则;执行以本规范为准,文档缺失先补文档再执行。
---
## 一、抽帧策略(双因素 + 首尾 + 补帧,⭐ 权威方案)
**核心原则**:只抽"有信息量的帧"——画面变化点 + 字幕变化点 + 首尾帧 + 长间隔补帧,避免固定节奏(fps=1)浪费静止帧。
| 因素 | 命令 | 阈值 | 捕捉内容 |
|------|------|:---:|---------|
| ① 画面变化 | `select='gt(scene,0.3)'` | 0.3 | 镜头切换/画面突变 |
| ② 字幕变化 | `crop=W:底1/4:0:Y` + `select='gt(scene,0.1)'` | 0.1 | 字幕换句(含画面静止时) |
| ③ 低阈值补漏 | `select='gt(scene,0.05)'` | 0.05 | 中等缓变(0.05~0.3 盲区,如 53.93s 案例) |
| ④ 首帧/尾帧 | `-ss 0` / `-ss DUR-0.3` | — | 开场/结尾画面(scene 不触发) |
| ⑤ **长间隔强制补帧** | 帧间隔 >5s → 该段中点强制抽 1 帧 | — | 物理兜底,杜绝长盲区(如 8.4s 空隙) |
**执行流程**:
1. 画面 scene 检测(0.3)→ 时间点 A
2. 字幕区 scene 检测(0.1)→ 时间点 B
3. **低阈值 scene 检测(0.05)→ 时间点 C**(补中等缓变盲区,**不是可选补救,是标准步骤**)
4. 合并去重 A∪B∪C → 基础帧集
5. **长间隔强制补帧**:排序后检查帧间隔,任一 >5s 则在该段中点补 1 帧(物理上无盲区)
6. 必加首帧(0s)+ 尾帧(`DUR-0.3` 或 `-ss 69.9`)
7. 逐时间点 `-ss` 抽帧(精确时间匹配 `eq(t,xx)` 命中率低,勿用)
**字幕区裁剪**(竖屏 720×960):字幕通常在底部 1/4 → `crop=720:240:0:720`。横屏或字幕位置不同时按实际调整。
## 二、抽帧命令模板
```bash
FF="/path/ffmpeg.exe"; VIDEO="video.mp4"; mkdir -p frames_combined
# ① 画面变化点
ffmpeg -i "$VIDEO" -vf "select='gt(scene,0.3)',showinfo" -vsync vfr -f null - 2>&1 \
| grep -oE "pts_time:[0-9.]+" | cut -d: -f2 | sort -n -u > scene_03.txt
# ② 字幕变化点(竖屏底部 1/4 区域)
ffmpeg -i "$VIDEO" -vf "crop=720:240:0:720,select='gt(scene,0.1)',showinfo" -vsync vfr -f null - 2>&1 \
| grep -oE "pts_time:[0-9.]+" | cut -d: -f2 | sort -n -u > subtitle_01.txt
# ③ 低阈值复查(scene=0.05,找中等缓变盲区)
ffmpeg -i "$VIDEO" -vf "select='gt(scene,0.05)',showinfo" -vsync vfr -f null - 2>&1 \
| grep -oE "pts_time:[0-9.]+" | cut -d: -f2 | sort -n -u > scene_005.txt
# ④ 合并 + 长间隔强制补帧(间隔 >5s 补中点,物理杜绝盲区)
sort -n -u scene_03.txt subtitle_01.txt scene_005.txt > times.txt
echo "0" >> times.txt # 首帧
DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$VIDEO")
# 长间隔补帧:遍历排序后时间点,间隔>5s 插入中点
prev=""
for t in $(sort -n -u times.txt); do
if [ -n "$prev" ]; then
gap=$(echo "$t - $prev" | bc)
if [ "$(echo "$gap > 5" | bc)" = "1" ]; then
mid=$(echo "($t + $prev) / 2" | bc)
echo "$mid" >> times.txt
fi
fi
prev="$t"
done
sort -n -u times.txt > times_final.txt
# ⑤ 逐时间点抽帧(尾帧用 DUR-0.3 防超界)
i=0
for t in $(cat times_final.txt); do
[ "$(echo "$t > $DUR" | bc)" = "1" ] && t=$(echo "$DUR - 0.3" | bc)
i=$((i+1))
ffmpeg -y -ss "$t" -i "$VIDEO" -frames:v 1 -q:v 2 "frames_combined/c_$(printf %02d $i)_${t}s.jpg" 2>/dev/null
done
```
## 三、帧命名规则
- `frames_combined/c_XX_时间s.jpg`(如 `c_01_2.233333s.jpg`)
- 时间戳保留原始精度(scene 检测输出浮点),便于按帧对应视频时刻
## 四、时间区间划分(每帧代表的时间段)
- **相邻帧中点划分(Voronoi)**:帧 i 代表 `[中点(i-1,i), 中点(i,i+1)]`
- 首帧从 0 起、尾帧到视频结束(DUR)
- 用于:分镜时间轴、字幕时间线、画面-台词对应
## 五、字幕提取
- 抖音字幕是**烧录在画面上的**(非独立字幕轨)→ 从帧画面转录(OCR / 视觉读取)
- 字幕区 scene 检测已保证**每个换句时刻都有一帧**(含画面静止时)
- **🔴 必须逐帧 OCR 字幕区**(见顶部核心铁律第 3 条):对每一帧都做 OCR,再按时间滚动去重,保留最完整版本。**禁止只对每个聚类「代表帧」OCR**——代表帧一旦模糊/过渡/fade,整句字幕会永久丢失(#010 事故根因)。
- 转录产出:`字幕稿_日期.md`,格式 = 时间线表(时间 | 台词 | 帧文件)
- 长间隔段(如 8~9s 无变化):该段字幕可能持续同句,读帧确认即可
## 六、三版抽帧对比(为什么不用固定节奏)
| 方案 | 帧数(70s) | 感知画面 | 感知字幕 | 结论 |
|------|:---:|:---:|:---:|------|
| I 帧 | 12 | ❌ | ❌ | 编码关键帧,漏字幕 |
| fps=1 | 70 | ❌ | ✅ | 固定节奏,浪费 56% |
| **双因素+首尾+补帧** | **31** | ✅ | ✅ | 精准全覆盖,省帧 |
## 七、常见坑
- `select='eq(t,2.233333)'` 精确时间匹配命中率极低(浮点误差)→ 用 `-ss` 逐点抽
- 尾帧 `-ss DUR` 会超出视频末尾 → 用 `DUR-0.3` 或 `-ss 69.9`(70s 视频)
- scene=0.05 会产生大量抖动点(相邻 <0.05s)→ 与基础帧集比对,近似点(<0.05s 差)不算遗漏
- 字幕区域随平台/画幅变化(抖音竖屏底部 1/4;B 站横屏底部 10-15%)→ 先抽一帧确认字幕位置再 crop
- **字幕区 crop 参考值**:抖音竖屏 720×960 → `crop=720:240:0:720`;B 站横屏 1080p → `crop=1920:162:0:918`(底部 15%)
- **CRLF 行尾坑**:时间戳列表若由 Python/Windows 生成(`\n` 被转成 `\r\n`),bash `while read` 的变量带 `\r` → ffmpeg `-ss "0.0\r"` 报 `Invalid duration for option ss` 全批失败。修复:先 `tr -d '\r' < times.txt > times_unix.txt` 再用,或 Python 写文件用 `newline='\n'`
- **逐点 -ss 抽帧耗时**:700+ 帧约 7 分钟(每帧一次 ffmpeg 启动),务必后台执行(run_in_background);帧序号用 4 位 `c_%04d`