在现代 Web 开发中,我们经常会遇到一些看似简单但实际上充满挑战的任务。最近,我就遇到了这样一个需求:从一个特定的视频网站下载一个视频。这个网站并非简单的静态页面,它大量使用 JavaScript 动态加载内容,并且部署了相当复杂的反爬虫和内容保护机制。这篇博客将详细记录我如何从零开始,使用 Node.js、TypeScript 和 Playwright,一步步构建出一个能够应对这些挑战的健壮下载工具的完整过程。

初步构想与技术选型

任务的起点非常明确:给定一个网页 URL,我需要将该页面上的主视频下载到本地。我的第一反应是检查网页的源代码,看看是否能直接找到 .mp4 文件的链接。然而,现实很快给了我一击。打开浏览器的开发者工具,我发现初始的 HTML 文档中根本没有任何直接指向视频文件的 URL。所有的视频播放器、视频源信息,都是在页面加载后,通过执行一系列复杂的 JavaScript 脚本才动态生成和注入到页面中的。

这立刻排除了使用 curlwget,甚至是 Node.js 中简单的 axiosnode-fetch 等传统 HTTP 请求库的方案。这些工具只能获取到最初的、不包含任何视频信息的 HTML 骨架。它们无法模拟一个真实的用户环境,无法执行 JavaScript,因此也就永远无法“看到”那个由脚本动态生成的视频播放器。

我立刻意识到,解决这个问题的关键在于模拟一个完整的浏览器环境。我需要一个工具,它不仅能加载网页,还要能像 Chrome 或 Firefox 一样,拥有一个 JavaScript 引擎来执行页面上的所有脚本,渲染 DOM,并响应用户的交互。

在技术选型上,我很快锁定了几个关键技术栈:

Node.js: 作为后端运行环境,它拥有强大的文件系统操作能力和丰富的生态系统,是构建这类自动化工具的理想平台。

TypeScript: 我选择 TypeScript 而不是原生 JavaScript,主要是出于对项目长期可维护性和健壮性的考虑。TypeScript 的静态类型检查可以在编码阶段就发现大量的潜在错误,比如拼写错误、类型不匹配等。对于一个需要处理复杂浏览器 API 和网络数据流的项目来说,类型安全能够极大地提升开发效率和代码质量。这不仅仅是一个个人偏好,更是一个工程决策。

Playwright: 在浏览器自动化工具方面,有两个主流选择:Puppeteer 和 Playwright。尽管两者都非常出色,但我最终选择了 Playwright。Playwright 是由微软推出的更现代化的工具,它提供了跨浏览器(Chromium, Firefox, WebKit)支持,API 设计更加简洁和强大,并且在处理等待、交互和网络拦截等方面提供了非常灵活和可靠的接口。我相信,对于一个可能存在复杂反爬机制的网站,Playwright 提供的强大网络控制能力将是至关重要的。

技术栈确定后,我开始了项目的搭建。这个过程本身就是展示一个工程师基本功底的环节。我创建了一个新的项目目录,使用 npm init -y 初始化了 package.json,然后安装了核心依赖 playwright 和开发依赖 typescript, ts-node, @types/node。接着,通过 npx tsc --init 创建了 tsconfig.json 文件。

我对 tsconfig.json 进行了精心的配置,使其符合现代 Node.js 开发的最佳实践。例如,我将 target 设置为 ES2020 以便使用 async/await 等现代语法,module 设置为 CommonJS(这是我们旅程初期的选择,后来会演进),并明确了 rootDiroutDir 来分离源码和编译产物。这些看似微小的配置,却是一个项目走向规范化的第一步。

模拟点击与被动监听

项目框架搭建完毕,我开始编写核心逻辑。我的第一个版本的核心思路非常直接,可以概括为“模拟用户,监听网络”。

首先,我设计了一个 VideoDownloader 类,将所有相关的操作封装起来。这是一个良好的工程习惯,可以使代码结构更清晰,易于扩展和维护。

我的第一步是实现最基本的用户行为模拟。我编写了几个核心的私有方法:initialize 用于启动 Playwright 并创建一个新的浏览器页面实例;navigateToPage 用于导航到用户提供的目标 URL;clickPlayButton 用于找到并点击页面上的播放按钮。

这里的关键假设是:视频的真实 URL 只有在用户点击了播放按钮之后才会开始加载。因此,我的主要任务就是找到那个播放按钮。通过检查页面的 DOM 结构,我定位到了播放按钮的 CSS 选择器,它是一个带有特定类名的 button 元素。

但是,仅仅点击按钮是不够的。点击之后,浏览器会发起对视频文件的网络请求,我需要捕获这个请求。Playwright 强大的网络监听功能在这里派上了用场。我在 initialize 方法中,紧接着创建页面实例之后,就注册了一个网络响应监听器:

// 核心代码片段 - 注册响应监听器
this.page.on('response', this.handleResponse.bind(this));

这个监听器会捕获页面上发生的所有网络响应。handleResponse 方法则是我实现的核心过滤和数据处理逻辑。

在这个方法里,我需要从成百上千的网络请求中精确地找出视频分片。通过在浏览器开发者工具的网络面板中观察,我发现了视频请求的几个显著特征:

第一,请求的 URL 包含特定的文件扩展名,比如 .mp4。 第二,视频通常是分片加载的,以支持拖动进度条和节省带宽。这种分片加载在 HTTP 协议中通过 Range 请求头和状态码 206 Partial Content 来实现。服务器返回的响应头里会包含一个 Content-Range 字段,明确告知这个分片在整个视频文件中的位置和总大小。

基于这些观察,我确定了我的过滤逻辑:只处理那些 URL 包含特定关键字(如 .mp4)且 HTTP 状态码为 206 的响应。

接下来的问题是,如何处理这些捕获到的、可能乱序到达的视频分片?如果简单地把它们放进一个数组里,最后拼接出来的文件很可能是损坏的。正确的做法是根据它们在完整视频中的位置来存储。Content-Range 头提供了这个关键信息,例如 bytes 0-5242879/906098800,表示这是从第 0 字节开始的数据块。

因此,我决定使用一个 Map 数据结构来存储分片,以分片的起始字节作为键(key),以包含二进制数据的 Buffer 作为值(value)。

// 核心代码片段 - 使用 Map 存储分片
private videoChunks: Map<number, Buffer> = new Map();

// 在 handleResponse 中
const rangeMatch = contentRange.match(/bytes (\d+)-(\d+)\/(\d+)/);
if (rangeMatch) {
    const start = Number.parseInt(rangeMatch[1], 10);
    const buffer = await response.body(); // 这是我们早期的一个错误,后面会详述
    this.videoChunks.set(start, buffer);
}

这种做法的优势在于,无论分片以何种顺序到达,我都能将它们精确地放置在正确的位置上。

最后一步是文件组装。当所有分片都下载完毕后,我需要将它们按正确的顺序写入一个本地文件。为了效率和内存考虑,我没有选择将所有分片在内存中拼接成一个巨大的 Buffer,而是利用了 Node.js 的流(Stream)API。我创建了一个 fs.createWriteStream,然后遍历 Map 中所有的键(也就是分片的起始字节),对这些键进行排序,再依次从 Map 中取出对应的 Buffer 写入到流中。这种流式写入的方式内存占用极低,即使是几十 GB 的大文件也能轻松处理。

至此,我的第一个版本的逻辑已经完整。它看起来很完美:模拟用户点击,监听网络,过滤视频分片,有序存储,高效写入。我满怀信心地运行了脚本。

超时与 networkidle 的陷阱

程序运行后,终端输出停在了“正在导航到页面…”,然后在一分钟后,一个鲜红的错误信息宣告了第一次尝试的失败:page.goto: Timeout 60000ms exceeded. waiting until "networkidle"

超时错误。这是自动化测试和网络爬虫中非常常见的问题。我立刻审查了我的 navigateToPage 方法:

// 早期错误代码
await this.page.goto(this.url, { waitUntil: 'networkidle', timeout: 60000 });

问题就出在 waitUntil: 'networkidle' 这个选项上。它的意思是告诉 Playwright,等到页面加载完毕,并且网络在 500 毫秒内没有任何新的活动时,才算导航成功。

这个选项在处理一些简单的、加载完就“安静”下来的静态页面时非常有用。然而,对于我正在处理的这个现代化的、动态的视频网站来说,这简直是一个不可能满足的条件。这类网站的前端应用非常复杂,它们在后台会持续不断地进行各种网络活动:

  • 发送用户行为分析和跟踪数据。
  • 定时刷新广告内容。
  • 通过长轮询或 WebSocket 与服务器保持连接以获取实时更新。
  • 预加载图片或其他媒体资源。

在这种情况下,页面的网络活动几乎永远不会“空闲”超过 500 毫秒。我的程序苦苦等待了 60 秒,最终只能无奈超时。

这次失败让我意识到,我的等待策略必须更加精确。我不需要等待整个页面都“风平浪静”,我只需要等到我需要交互的那个关键元素——播放按钮——出现并可用即可。

我迅速调整了策略。首先,我将 page.goto 的等待条件从 networkidle 放宽为 domcontentloaded。这个选项告诉 Playwright,只要页面的核心 HTML 文档加载并解析完成,就可以继续执行下一步,无需等待所有图片、样式表和异步脚本加载完毕。这使得导航步骤能够快速、可靠地完成。

然后,我将真正的、精确的等待逻辑放在了 clickPlayButton 方法中。这个方法内部本来就已经有了 await this.page.waitForSelector(playButtonSelector, ...) 的调用。这才是正确的“等待”,因为它等待的是一个具体的目标,而不是一个模糊的、难以达成的网络状态。

在修复这个问题的过程中,我还做了一个额外的优化。我意识到,那些持续不断的后台网络活动,很多都与广告和追踪脚本有关。这些请求不仅拖慢了页面加载速度,增加了 networkidle 超时的风险,而且对于我的下载任务来说是完全无用的。于是,我决定在导航之前就设置一个请求拦截器,主动屏蔽掉这些不必要的请求。

// 核心代码片段 - 屏蔽不必要的请求
await this.page.route('**/*', (route) => {
    const url = route.request().url();
    if (url.includes('google-analytics.com') || url.includes('doubleclick.net')) {
        return route.abort();
    }
    return route.continue();
});

这个小小的改动,不仅让页面加载更快,也让我的自动化脚本运行环境更干净、更可预测。这体现了一个工程师从“让它工作”到“让它工作得更好”的思维转变。

竞态条件

解决了导航超时问题后,我再次运行程序。这一次,日志显示页面成功加载,播放按钮也被成功点击。我仿佛听到了胜利的号角。然而,几秒钟后,一个新的、更诡异的错误出现了:Protocol error (Network.getResponseBody): No data found for resource with given identifier

程序捕获到了视频分片的响应(response 对象),但在尝试获取其内容(await response.body())时失败了。错误信息非常底层,直指浏览器开发工具协议(CDP)的层面。

我花了很多时间来调试和理解这个错误。起初我怀疑是 Playwright 的 Bug,或者是网站有什么特殊的加密。但经过反复试验和查阅资料,我最终锁定了问题的根源——一个经典而棘手的“竞态条件”(Race Condition)。

我的代码和浏览器内部的媒体处理管线,正在进行一场速度竞赛,而我输了。

事情的经过是这样的:

  1. 浏览器发起对视频分片的请求。
  2. 服务器返回数据,Playwright 的底层通过 CDP 协议检测到了这个响应,并通知了我的 Node.js 进程。
  3. Node.js 的事件循环机制将一个 'response' 事件放入事件队列。
  4. 与此同时,浏览器作为一个高度优化的 C++ 程序,并没有等待我的 Node.js 脚本。它的网络堆栈收到视频数据后,可能立即就通过“零拷贝”之类的技术将数据直接送往 GPU 或媒体解码器进行播放。为了极致的性能,它在完成这个操作后,会立刻从内存中清除这份数据,因为它认为这份数据已经“消费”完毕。
  5. 轮到 Node.js 的事件循环处理我的 'response' 事件时,我的 handleResponse 回调函数开始执行。当代码运行到 await response.body() 时,它通过 CDP 协议向浏览器请求:“请把刚才那个响应的数据给我。”
  6. 浏览器回复:“抱歉,你说的那个东西,我早就处理完扔掉了。数据已经不在了。” 于是,No data found for resource 的错误就发生了。

这个过程中的延迟可能只有几毫秒甚至微秒,但在高性能的浏览器内核面前,这点时间足以让数据消失。我意识到,任何形式的“事后监听”,即在响应发生之后再去尝试获取数据的方案,都存在失败的风险。

为了战胜这个竞态条件,我需要一种更主动、更具侵入性的方法。我再次求助于 page.route。这次,我不仅仅是用它来 abort() 请求,而是要用它来完全地“代理”视频请求。我的新思路是:

  1. 拦截一个即将发出的视频请求。
  2. 不让浏览器自己去请求,而是调用 route.fetch(),让 Playwright 的后端代替浏览器去完成这个请求。
  3. 拿到 route.fetch() 返回的 APIResponse 对象后,我可以安全地从中 await response.body(),因为这是我自己的请求,数据被完整地保留了下来。
  4. 获取到数据并存入我的 videoChunks Map 之后,我再调用 route.fulfill({ response }),将我获取到的这个响应“伪造”一份交还给浏览器,让页面上的播放器以为一切正常。

这个方案理论上是完美的。它把被动的监听变成了主动的代理,从根本上解决了竞态问题。我重构了代码,满怀信心地再次运行。

程序运行了!我成功地捕获到了第一个、第二个分片的数据!然而,喜悦是短暂的。下载了几个分片(大约 9MB)之后,网络请求戛然而止。程序在等待 20 秒后超时退出,留下一个不完整的文件。

为什么下载链会中断?我的 route.fulfill() 难道没有成功“欺骗”播放器吗?

为了找出真相,我为我的拦截器添加了极其详尽的日志,打印每一个被拦截的请求的 URL、方法、类型,以及视频响应的完整头信息。

日志很快揭示了第一个惊人的发现:播放器在点击播放后,同时并发地请求了多个不同分辨率的视频流!例如 720P.mp4, 1440P.mp4, 2048P.mp4。它似乎是在探测当前网速最适合哪个码率。

而我的代码存在一个严重的逻辑缺陷:我用一个全局的 activeVideoUrl 变量来锁定我下载的第一个视频流。当 720P.mp4 的请求先到时,我的程序就“认定”了这是要下载的目标,然后无情地忽略了所有后续的 1440P2048P 请求。然而,播放器在短暂的探测后,可能最终决定持续播放的是 2048P 的流。由于我的程序没有正确地 fulfill 2048P 的请求(因为它忽略了它们),播放器的状态机被破坏,于是它停止了所有后续的请求。

我立刻修正了这个逻辑。我不再“先入为主”,而是维护一个所有候选流的列表。下载结束后,我再从中选择一个“最佳”的流进行组装。我最初的“最佳”标准是“分片数量最多”。

然而,再次运行后,问题依旧。日志显示,所有分辨率的流都只被请求了 2 个分片,然后就都停止了。我的“选择分片数量最多”的策略在这种情况下退化成了“选择第一个”,依然是错误的。

这时,我才终于触及了问题的本质。不是我的选择逻辑有问题,而是**route.fulfill() 本身就是有问题的**。我用 route.fetch() 获取的 APIResponse 对象,虽然包含了数据,但它终究是一个“克隆品”。当通过 route.fulfill() 交还给浏览器时,它可能丢失了某些对播放器至关重要的底层连接句柄、时序信息或其他元数据。这个“不完美”的响应,虽然能提供最初的几个分片数据,但足以破坏播放器精密的内部状态,导致它无法或不愿请求后续的分片。

从被动监听到主动控制

在经历了 page.on('response') 的竞态失败和 route.fulfill() 的状态破坏之后,我陷入了沉思。我似乎陷入了一个两难的境地:要么太慢,要么干扰太大。

就在这时,一个全新的思路在我脑中形成。我之前所有的尝试,都围绕着一个核心思想:如何从浏览器发起的请求中“窃取”数据。无论是监听还是代理,我都是一个依附于浏览器行为的“寄生者”。

为什么不反过来呢?我为什么不能成为下载过程的主导者?

这个想法彻底改变了我的整个架构。新的、最终的方案诞生了,它分为两个明确的阶段:侦察接管

侦察

我依然使用 page.route 拦截网络请求。但这一次,它的目的变得极其单纯:只看不碰。当一个视频分片请求被拦截时,我什么也不做,只是立刻调用 route.continue(),让浏览器畅通无阻地进行它自己的网络通信。这保证了播放器状态的绝对纯净和连续。 同时,我在后台悄悄地使用 page.waitForResponse() 等待这个刚刚被我放行的请求完成。当响应到达时,我从中解析出我需要的所有信息:完整的 URL(包含动态生成的 token)、视频总大小、以及分辨率。我将这些信息存入我的 discoveredStreams Map 中。 我设置一个 5-10 秒的侦察窗口。在这段时间里,播放器会像往常一样探测所有分辨率的流。我的侦察器会收集到所有这些流的信息。侦察窗口结束后,我检查 discoveredStreams,选择分辨率最高的那个流。至此,我拥有了下载完整高清视频所需的一切关键情报。

接管

侦察任务完成,我不再关心浏览器后续的网络活动。我启动一个全新的、由我完全控制的下载循环。在这个循环中,我将执行整个方案中最核心、最精妙的操作:在浏览器内部为我工作。

我使用 page.evaluate() 函数。这个函数可以在浏览器页面的上下文中执行任意的 JavaScript 代码。我在 Node.js 端计算出我需要下载的下一个分片的范围,例如 bytes=0-5242879,然后将这个范围和侦察到的高分辨率 URL 一起作为参数传递给 page.evaluate

// 核心代码片段 - 在浏览器内发起 fetch 请求
const chunkData = await this.page.evaluate(async ({ url, range }) => {
    const response = await fetch(url, { headers: { 'Range': range } });
    if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
    const buffer = await response.arrayBuffer();
    return { data: Array.from(new Uint8Array(buffer)) };
}, { url, range });

这段代码的魔力在于,fetch(url, ...) 是在浏览器内部执行的。这意味着这个请求:

  • 自动携带了当前页面的所有 cookies 和 session 信息。
  • 源自于同一个 IP 地址和浏览器指纹。
  • 完全通过了网站可能部署的任何 Cloudflare 或其他 JavaScript 人机验证。

它是一个完美的、合法的、源自于真实用户会话的请求。服务器无法将其与播放器自身的请求区分开来。

fetch 的响应是一个 ArrayBuffer,这是一个无法直接从浏览器传回 Node.js 的对象。因此,我巧妙地将其转换为一个普通的 JavaScript 数字数组 Array.from(new Uint8Array(buffer)),这是一个可以被序列化为 JSON 并跨进程边界传递的数据结构。

在 Node.js 端,我接收到这个数字数组,再用 Buffer.from(chunkData.data) 将其转换回 Node.js 的 Buffer 对象,然后稳稳地写入文件流。

我以一个 for 循环重复这个过程,按照我设定的分片大小,从头到尾,顺序地请求整个视频文件,直到下载完所有字节。

这个方案最终被证明是无懈可击的。它彻底规避了竞态条件,因为它不再被动监听。它也完全避免了干扰播放器状态,因为它让浏览器的归浏览器,下载的归下载。我们只是借用了浏览器的“身份”去发我们自己的请求。

完善用户体验和健壮性

解决了核心的下载难题后,我并没有就此止步。一个优秀的工程师不仅要解决问题,还要交付一个好用的工具。我开始为这个脚本添加一系列专业化的功能。

支持命令行参数

我移除了硬编码的 TARGET_URL,改为使用 Node.js 的 process.argv 来解析命令行参数。现在,用户可以直接在 npm start 后面跟上任何想下载的视频 URL,极大地提高了工具的灵活性。我还添加了参数检查,如果用户没有提供 URL,程序会打印出用法提示并友好地退出。

实现丰富的进度显示

一个漫长的下载过程如果没有进度反馈,体验是非常糟糕的。我创建了一个独立的 DownloadTracker 辅助类,专门用于处理下载统计。它在下载开始时记录一个时间戳,每次收到数据块时更新已下载的字节数。 为了避免因频繁更新而刷屏,我使用了一个简单的节流技巧:进度信息最多每秒更新一次。在每次更新时,它会计算:

  • 平均下载速度(Mbps): (总下载字节 * 8) / 经过的秒数 / 1024 / 1024
  • 剩余时间(秒): (总大小 - 已下载大小) / 平均速度
  • 预计完成时间(ETA): 当前时间 + 剩余时间 然后,我将这些信息格式化成一行清晰的、不断刷新的状态栏,例如: Progress: 25.13% | 216.05MB / 860.00MB | Speed: 123.45 Mbps | ETA: 19:30:45 (55s remaining) 这为用户提供了极佳的实时反馈。

增加下载重试机制

网络是不稳定的。在下载一个大文件的过程中,任何一个分片因为暂时的网络波动而失败,都不应该导致整个任务的中止。我为下载单个分片的逻辑 downloadChunkWithRetries 增加了一个 while 循环实现的重试机制。 如果一次 fetch 失败,它不会立即放弃,而是会进入 catch 块。在这里,我会增加重试计数器,并采用“指数退避”策略计算一个等待时间(例如,第1次重试等2秒,第2次等4秒,第3次等8秒……)。在短暂等待后,它会再次尝试下载同一个分片。只有在达到最大重试次数(例如5次)后,程序才会最终放弃并中止整个下载。这个机制极大地增强了程序在不稳定网络环境下的健壮性。

智能文件名提取

最后:从页面中提取有意义的标题作为文件名。我编写了一个 extractVideoTitle 方法,它会按优先级顺序查找页面中的特定元素(先是 p[lang="ja"],然后是 h2.mt-16),如果都找不到,则回退到使用 URL 的一部分。我还对提取出的标题进行了清理,移除了所有在文件名中非法的字符。最终,下载的文件被命名为 [视频标题]-[分辨率].mp4,一目了然。