name: long-video-course-notes description: 把 1-3 小时的长视频课程(直播回放、网课、Bilibili 讲座等)自动化生成图文并茂的飞书文档笔记。流程是:登录 → 提取 video 元素 → 用 video→canvas 抓取关键时间点的帧(绕过 HLS DRM)→ AI 看帧识别 PPT vs 讲师人头 → 在 PPT 密集段补抓 → 写带时间戳的 markdown 文章 → 推到飞书文档(自动建文件夹+插图)。当用户说「把这个视频整理成笔记」「长视频图文笔记」「视频课图文摘要发飞书」时使用。
长视频课程图文笔记 skill
何时用
- 用户给了一个长视频 URL(>30 分钟),希望生成图文摘要而不是只看一遍
- 视频是直播回放 / 网课 / 讲座这种主体是 PPT + 讲师讲解的内容(非娱乐短片)
- 输出目标是 飞书文档(默认)或本地 markdown
- 视频平台支持任何用
<video>HTML5 元素的网站(小鹅通、生财有术、Bilibili、B站、网易云课堂、抖音教育等)
何时不用
- 视频用 Flash / Silverlight / 私有播放器(无 HTML5 video 元素)
- 视频 < 15 分钟(直接看完更快)
- 视频内容是纯娱乐(音乐、Vlog、动画)—— 抓帧没价值
- 用户希望保留口播文字稿 —— 这个 skill 不做 ASR,需另用 whisper 或飞书妙记
核心原理(为什么这套行)
视频流通常是 HLS / MPEG-DASH,加上 token 鉴权——直接 ffmpeg 下载会 403。但浏览器内的 video 元素已经把流解密为帧缓冲,用 canvas.drawImage(video, ...) 可以拿到任意时间点的 PNG,绕开整个版权链。
video 元素 (DRM-free 解密后) → canvas.drawImage → toDataURL → 本地 jpg
流程总览
Step 1: 评估视频可访问性(登录态 / 是否 HTML5 video)
Step 2: 探针——拿到 duration / resolution / src
Step 3: 粗采样(每 8-10 分钟一帧)
Step 4: AI 看帧分类(PPT vs 人头 vs 编辑器 vs Q&A)
Step 5: 在 PPT 密集区补采样
Step 6: 写 markdown 文章
Step 7: 推到飞书(建文件夹 + 创建文档 + 逐张插图)
Step 1:登录 / 可访问性检查
用 Playwright MCP 浏览器打开视频 URL:
mcp__playwright__browser_navigate(url=视频URL)
检查:
- 如果跳转到登录页 → 看下面「登录策略」
- 如果直接看到课程页 → 跳到 Step 2
登录策略(按优先级)
A. 平台已登录(最常见,免操作) — Playwright MCP 浏览器在同一会话内会保留 cookie,如果用户最近用 Playwright 访问过同一域名并登录,cookie 还在。直接试导航。
B. 用户在自己 Chrome 里登录 → cookie 导入 — 用 ~/.claude/skills/gstack/browse 的 cookie-import-browser:
cd ~/.claude/skills/gstack/browse
bun run src/cli.ts goto "https://target.com/" # 必须先访问目标域
bun run src/cli.ts cookie-import-browser chrome --domain target.com
⚠️ 失败常见原因:用户 Chrome 里 cookie 已过期(看 expires_utc 字段)。
C. 无人值守登录不可行 → 让用户做 — 验证码 / 滑块 / 微信扫码这些永远做不到自动化,老老实实让用户登录:
- 给清晰的 1 句话指引:「在 Chrome 里打开 X,登录,然后告诉我」
- 不要发 2 段话以上,不要给 4 个选项
绝对不要陷入:试错登录→cookie 过期→换工具→工具又挂→再换工具的死循环。最多尝试 2 次后直接降级到 Plan B(用户自己截图发我)。
Step 2:视频元素探针
打开视频页后,运行:
() => {
const v = document.querySelector('video');
if (!v) return {error: 'no video element'};
return {
src: v.src || v.currentSrc,
duration: v.duration,
width: v.videoWidth,
height: v.videoHeight,
paused: v.paused,
readyState: v.readyState,
title: document.title,
url: location.href
};
}
判断:
duration > 0且readyState >= 3→ 可以抓帧src是blob:...URL → 用的 MSE,正是我们要的videoWidth >= 720→ 帧质量够readyState < 3→ 视频未加载,可能需要点播放按钮先触发加载(跳过 muted 自动播放更稳)
Step 3:粗采样关键时间点
对一个 N 分钟的视频,先采样 ceil(N/8) 帧(每 8 分钟一帧),覆盖整体节奏:
async () => {
const v = document.querySelector('video');
v.pause(); v.muted = true;
const duration = v.duration;
const interval = 480; // 8 分钟
const targets = [];
for (let t = 30; t < duration - 30; t += interval) targets.push(Math.floor(t));
const seekTo = (t) => new Promise((resolve) => {
const onSeeked = () => { v.removeEventListener('seeked', onSeeked); resolve(); };
v.addEventListener('seeked', onSeeked);
v.currentTime = t;
setTimeout(() => { v.removeEventListener('seeked', onSeeked); resolve(); }, 8000);
});
const canvas = document.createElement('canvas');
canvas.width = v.videoWidth;
canvas.height = v.videoHeight;
const ctx = canvas.getContext('2d');
window.__frames = window.__frames || {};
const out = [];
for (const t of targets) {
await seekTo(t);
v.pause();
await new Promise(r => setTimeout(r, 800)); // 等帧缓冲
ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
window.__frames[t] = canvas.toDataURL('image/jpeg', 0.8);
out.push({t: v.currentTime, target: t, kb: (window.__frames[t].length / 1024) | 0});
}
return out;
}
关键细节:
v.muted = true:避免 autoplay 检测拦截v.pause()在每次 seek 后再调一次(有些播放器在 seek 后会自动 resume)- 800ms 等待:seeked 事件触发不代表帧已渲染,要给 GPU buffer 一点时间,否则会拍到上一个时间点的帧
- 都 stash 在
window.__frames,避免一次返回 30MB 巨型 base64
Step 4:取出 base64 写盘
每次 evaluate 调用最多返回 ~5MB JSON。分批:
() => {
const keys = Object.keys(window.__frames).sort((a,b) => +a - +b);
const out = {};
for (const k of keys.slice(START, START+4)) {
out[k] = window.__frames[k].split(',')[1]; // 去掉 data:image/jpeg;base64,
}
return out;
}
mcp__playwright__browser_evaluate 的 filename 参数把 JSON 写到 ~/.playwright-mcp/ 或 ~/。不能直接写 /tmp(沙箱限制)。
落盘脚本:
import json, base64, os
os.makedirs(os.path.expanduser("~/course-frames"), exist_ok=True)
for batch in glob("~/course-frames/batch*.json"):
d = json.load(open(batch))
for t, b64 in d.items():
secs = int(t)
fn = f"frame-{secs:04d}s-{secs//60:03d}m{secs%60:02d}s.jpg"
open(f"~/course-frames/{fn}", "wb").write(base64.b64decode(b64))
Step 5:AI 看帧分类 + 补采样
用 Read 工具逐帧查看(图片会自动显示给你)。给每帧打标:
slide—— 有 PPT 文字、可读 → 必抓editor—— Cursor / VS Code / 终端 → 抓(演示场景有用)talking_head—— 只是讲师人脸 → 跳过qa/chat—— 弹幕互动 → 选 1-2 张作 Q&A 代表
密集补采样:如果连续 2 帧之间跨度 8 分钟但内容完全不同(如从 Principle 01 跳到 Principle 05),说明这段 PPT 切换密集,回头补采样:
// 在 [start, end] 范围按 1-2 分钟间隔补采
const targets = [];
for (let t = START; t <= END; t += 90) targets.push(t);
Step 6:写 markdown 文章
文章结构(根据课程类型微调):
# 课程标题 · 完整复盘笔记
> 课程信息:讲师 / 日期 / 时长 / 平台 / 原视频链接
## 这篇文章给谁看
(1 段,直接说明读者画像 + 学到什么)
## 课程整体结构
00:00 - 08:00 开场 08:00 - 25:00 Part 1 ... ...
## Part 1 · ...
> ⏱ 时间码:**16:00 – 25:00**
### 子章节 1
> ⏱ **16:00** · 课件 9/38

(关键观点 + 钉子句 + 检查清单)
...
## 给小白的可执行下一步
(按周拆 Action Items)
## 附录 · 关键资源链接
写作规则:
- 每个 image placeholder 用唯一文件名
placeholder-frame-{seconds}s.jpg,便于后面 selection-with-ellipsis 定位 - 每个 PPT 必标精确时间码(hh:mm 或 mm:ss)+ 课件页码
- 课件 PPT 内容直接抄到表格里,不要总结(用户要看原文)
- 反例/正例 / 检查清单这些结构化内容用 markdown 表格或列表
- 文末必有「给小白的可执行下一步」按周拆,否则文章只是 read-only
Step 7:推飞书
7.1 检查 lark-cli 授权状态
lark-cli auth status
第一次跑这个 skill 需要授权这些 scope(按需,逐个加):
space:folder:create— 建文件夹docx:document:create— 创建文档docx:document:write_only+docx:document:readonly— 修改文档(媒体插入需要)docs:document.media:upload— 上传图片到文档
授权命令:
nohup lark-cli auth login --scope "<scope>" > /tmp/lark-auth.log 2>&1 &
sleep 5
cat /tmp/lark-auth.log # 拿到 device-flow URL
open "<URL>" # 让用户在浏览器授权
# 等用户完成
until grep -q "授权成功" /tmp/lark-auth.log; do sleep 3; done
⚠️ scope 必须一次只加一个——多 scope 一起请求会报 invalid scope。
7.2 建文件夹(默认在用户个人空间根目录)
lark-cli drive +create-folder --name "<文件夹名>"
# 返回 folder_token,记下来
7.3 准备干净版 markdown(无图片占位)
sed '/!\[.*\](placeholder-frame/d' article.md > article-clean.md
7.4 创建文档
cd ~/course-frames/article # markdown 路径必须 cd 进去(CLI 限制)
lark-cli docs +create \
--markdown @article-clean.md \
--folder-token "<folder_token>" \
--title "<文档标题>" \
> /tmp/doc-create.json 2>&1
# 提取 doc_id
7.5 逐张插入图片
每张图按章节标题或独特文本作 anchor:
lark-cli docs +media-insert \
--doc "<doc_id>" \
--file "frame-XXXXs-MMMmSSs.jpg" \
--type image \
--selection-with-ellipsis "<unique heading text>" \
--align center \
--caption "<时间码 · 描述>"
Anchor 规则:
- 用章节标题前 15 个字(够 unique 即可)
- 同一章节多张图 → 用不同段落的独特短语
- 失败的话用
start...end格式:--selection-with-ellipsis "首段...末段"
7.6 失败处理
- 服务器超时 → 重试 1 次。超过 16k 字符的文档创建容易超时,文章拆成两段也行
- "必须在 root folder 创建" 错误 → folder-token 拼错或没传
- 图片占位仍存在 → markdown 里
placeholder-frame-XXX残留,先用--mode overwrite重写文档干净版再插
反模式(避免踩坑)
❌ 反模式 1:试图 ffmpeg 直接下载视频
HLS + token 几乎一定会 403。即使能下,也要花 20 分钟下载完整视频,再花 5 分钟 ffmpeg 抽帧。video → canvas 用 5 秒抓 16 帧。
❌ 反模式 2:让 Claude 看完整视频
你没有那个能力。即使能(多模态视频 API),成本也是抓帧的 50 倍。抓帧 + 看图 + 时间戳定位才是杠杆。
❌ 反模式 3:seek 没等够时间就 drawImage
seeked 事件触发 ≠ 帧渲染完成。GPU buffer 有 200-500ms 延迟。至少 800ms,1 秒更稳。表现是「前两帧 OK,第三帧开始全是同一张图」。
❌ 反模式 4:scope 一把抓
lark-cli auth login --scope "scope1 scope2 scope3" 报 invalid scope。一次一个。
❌ 反模式 5:试图用 gstack browse 同时跑视频和飞书操作
gstack browse daemon 在 bash session 切换时不稳定。Playwright MCP 抓视频 + lark-cli 操作飞书 最干净。
❌ 反模式 6:cookie 过期还在硬试
检查 expires_utc < 当前时间 → 直接告诉用户「重新登录」,不要换 5 种方案试。
常见用户问题预案
「为什么不直接给我视频字幕?」 答:可以,但需要单独跑 whisper(额外 5-10 分钟)。如果用户明确要文字稿就跑;否则图文笔记是默认。
「为什么不抓更多帧?」 答:每帧 ~200KB,飞书文档插入有体积限制 + 视觉疲劳。12-18 张是甜点,覆盖一节 2 小时课。
「能不能不要飞书?」
答:默认输出 ~/course-frames/article/article.md + 同目录 *.jpg,用户可手动发任何地方。
输出规范
每次成功跑完,最终响应必须包括:
- 飞书文档 URL(直接可点击)
- 抓帧总数 + 总时长
- 章节小目录(让用户预览)
- 一句**「下一步建议」**(如「评论里告诉我哪段要扩写」)
配套脚本
scripts/capture-frames.js —— Playwright evaluate 用的探针 + 抓帧函数(粘到 mcp__playwright__browser_evaluate 里)
scripts/decode-batches.py —— 落盘批量 base64 → jpg
scripts/insert-images.sh —— 飞书 lark-cli 批量插图模板
参考一个完整跑通的 case:examples/deepsea-2026-04-23.md