视频提链刚开始很容易写成一个脚本:
读取一批 URL -> 一个个打开页面 -> 找到视频地址 -> 下载到本地
这个版本可以验证思路,但不能长期运行。原因很简单:视频页面加载慢,部分页面会失败,Chrome Headless 比普通 HTTP 请求重,视频下载也耗时。如果所有逻辑都在一个进程里顺序执行,速度慢、稳定性差,出错后也很难恢复。
所以这套系统后面更接近一个任务队列,而不是一个爬虫脚本。
任务队列是系统的核心
视频抓取任务可以抽象成一条任务记录:
{ "id": "task_10001", "url": "https://example.com/video/123", "status": "pending", "retryCount": 0, "createdAt": 1503000000000}
任务状态流转大概是:
pending -> running -> success -> failed -> retrying
每个 worker 从队列里取一个 pending 任务,标记为 running,然后执行抓取。
async function runWorker() { while (true) { const task = await taskQueue.take(); if (!task) { await sleep(1000); continue; } try { await processTask(task); await taskQueue.markSuccess(task.id); } catch (error) { await taskQueue.markFailed(task.id, error); } }}
队列化以后,系统就具备几个能力:
- 可以持续消费任务。
- 单个任务失败不影响整体。
- 可以统计成功率和失败原因。
- 可以控制并发数量。
- 可以重试失败任务。
- 可以暂停、恢复或重新投递任务。
这比一个线性脚本可靠得多。
一个任务内部可以拆成几个阶段
视频抓取任务本身也不是一个步骤。
可以拆成:
加载页面 -> 等待页面稳定 -> 触发播放 -> 监听网络请求 -> 识别视频资源 -> 下载视频 -> 校验文件 -> 保存结果
用代码表达,大概是:
async function processTask(task) { const page = await browser.newPage(); try { const collector = createVideoCollector(page); await page.goto(task.url, { waitUntil: "networkidle2", timeout: 30000 }); await tryClickPlayButton(page); const video = await collector.waitForVideoUrl(15000); const file = await downloadVideo(video); await saveResult(task.id, { videoUrl: video.url, filePath: file.path, size: file.size }); } finally { await page.close(); }}
这里最重要的是 finally。Chrome 页面必须及时关闭,否则长时间运行后内存会不断上涨。Headless 系统的稳定性,经常不是毁在提链逻辑,而是毁在资源没有释放。
Node.js 负责调度,不负责重计算
这套系统用 Node.js 比较合适,因为它主要是 I/O 密集型:
打开网页等待网络请求下载视频写文件记录任务状态
Node.js 很适合做这种异步调度。
但要注意,Node.js 主线程不适合做大量 CPU 计算。比如视频转码、复杂文件处理、分片合并等,如果直接在 Node.js 里做,会阻塞事件循环。
对于 M3U8 合并,应该交给 ffmpeg:
import { spawn } from "child_process";function mergeM3u8(input, output) { return new Promise((resolve, reject) => { const ffmpeg = spawn("ffmpeg", [ "-i", input, "-c", "copy", output ]); ffmpeg.on("exit", code => { if (code === 0) resolve(); else reject(new Error(`ffmpeg exited with ${code}`)); }); });}
Node.js 做调度,Chrome Headless 做页面运行,ffmpeg 做视频处理。每个工具负责自己擅长的部分。
PM2 多进程用于并行处理
单进程跑 Headless Chrome,吞吐很有限。
如果任务很多,可以通过 PM2 启动多个 worker 进程:
pm2 start worker.js -i 4 --name video-crawler
这里的 -i 4 表示启动 4 个进程。每个进程都是一个独立 worker,从同一个任务队列里取任务。
并行模型是:
Task Queue -> Worker 1 -> Worker 2 -> Worker 3 -> Worker 4
多进程的好处是:
- 提高任务处理吞吐。
- 单个进程异常退出不影响全部任务。
- 可以利用多核 CPU。
- PM2 可以自动重启异常进程。
但多进程不是越多越好。每个 worker 可能都会启动 Chrome 页面,CPU、内存和带宽都会被占用。并发过高会导致整体更慢,甚至让机器不稳定。
更稳妥的方式是给每个进程设置内部并发上限:
const CONCURRENCY = 2;async function startWorkerPool() { await Promise.all( Array.from({ length: CONCURRENCY }).map(() => runWorker()) );}
最终并发数是:
PM2 进程数 × 单进程并发数
这个值要根据机器配置、目标站点速度、下载带宽和 Chrome 内存占用调。
多进程下任务领取要避免重复
多进程消费同一个队列时,最重要的是避免重复领取任务。
错误做法是:
Worker A 查到 task_1 是 pendingWorker B 也查到 task_1 是 pending两个 worker 同时处理同一个任务
任务领取必须是原子的。
如果用数据库,可以通过状态更新做抢占:
update tasksset status = 'running', worker_id = $workerId, started_at = now()where id = ( select id from tasks where status = 'pending' order by created_at asc limit 1 for update skip locked)returning *;
如果用 Redis,可以用 list 或 stream。核心原则是一样的:一个任务同一时间只能被一个 worker 拿到。
这个问题在单进程脚本里不存在,但一旦上 PM2 多进程,就必须解决。
失败重试要分类型
视频抓取失败很常见,但不是所有失败都应该重试。
可以把失败分成几类:
| 类型 | 是否重试 | 说明 |
|---|---|---|
| 页面超时 | 可以重试 | 可能是网络抖动 |
| 视频地址未找到 | 可以有限重试 | 可能是页面加载慢 |
| 404 | 不重试 | 资源不存在 |
| 解析失败 | 不一定 | 可能需要适配规则 |
| 下载中断 | 可以断点或重试 | 网络或目标站点问题 |
| ffmpeg 合并失败 | 看错误原因 | 可能是分片缺失 |
重试也不能立即无限重试。可以使用退避策略:
第 1 次失败:1 分钟后重试第 2 次失败:5 分钟后重试第 3 次失败:30 分钟后重试超过上限:进入 failed
这样不会因为一批坏任务把 worker 全部拖住。
下载阶段要和提链阶段解耦
一个任务里最耗时的可能不是打开页面,而是下载视频。
如果 worker 一直等待大文件下载完成,Chrome 资源可能被长期占用。更合理的方式是把“提链”和“下载”拆成两个队列:
提链队列 -> 找到视频地址 -> 写入下载队列下载队列 -> 下载 MP4 或 M3U8 -> 保存文件 -> 更新任务结果
这样 Chrome worker 专注于页面提链,download worker 专注于文件下载。两类任务可以设置不同并发:
Chrome worker 并发低一些Download worker 并发按带宽控制
这比一个 worker 从头干到尾更容易扩展。
需要保留可观察性
爬虫系统如果没有日志,很快会变成黑盒。
至少要记录:
任务总数pending / running / success / failed 数量平均处理耗时提链成功率下载成功率失败原因分布单个 worker 当前处理任务Chrome 页面异常退出次数
任务日志可以按阶段记录:
[task_10001] start[task_10001] page loaded 1820ms[task_10001] video url found mp4[task_10001] download success 35MB[task_10001] done 12.4s
有了这些日志,才能判断是页面提链慢、下载慢、M3U8 合并慢,还是某个站点规则失效。
小结
视频爬虫系统不能只写一个脚本跑到底。
真正可用的系统至少要有:
- 任务队列。
- worker 消费模型。
- Chrome Headless 提链。
- Node.js 异步调度。
- PM2 多进程并行处理。
- 原子任务领取。
- 失败重试和退避。
- MP4 / M3U8 下载处理。
- 日志和可观察性。
单个脚本能证明方案可行,但任务队列和多进程模型才能让它持续运行。对这种 I/O 密集、失败率不低、耗时不可控的系统来说,工程化设计比某一段爬取代码更重要。