视频加载失败

自建抖音无水印解析站原理:不依赖第三方 API 的三级降级架构

自建抖音无水印解析站原理:不依赖第三方 API 的三级降级架构
2108 字
11 分钟
自建抖音无水印解析站原理:不依赖第三方 API 的三级降级架构

这是什么#

一个跑在 Cloudflare Pages 免费额度上的抖音无水印解析工具。整站没有调用任何第三方解析服务,直接请求抖音官方接口,用三级降级策略应对风控变化。

前端纯 HTML + 原生 JS → Cloudflare Pages Functions 后端 → 抖音官方 API

后端 1000 行出头,devDependency 只有 wrangler 一个。没有服务器,没有数据库,也没有 Puppeteer 那类无头浏览器。

整体架构#

抖音 CDN

抖音 / 字节跳动

Cloudflare Pages

用户浏览器

POST /api/entry?action=parse

代理注入 Referer

直连 referrerpolicy=no-referrer

index.html 纯静态

Functions 入口 entry.js

core.js 核心解析

短链重定向 v.douyin.com → iesdouyin.com

ttwid 注册 ttwid.bytedance.com/union/register

Share 页 SSR iesdouyin.com/share/video

Web API aweme/v1/web/aweme/detail

视频 CDN

图片 CDN

抖音 CDN

抖音 / 字节跳动

Cloudflare Pages

用户浏览器

POST /api/entry?action=parse

代理注入 Referer

直连 referrerpolicy=no-referrer

index.html 纯静态

Functions 入口 entry.js

core.js 核心解析

短链重定向 v.douyin.com → iesdouyin.com

ttwid 注册 ttwid.bytedance.com/union/register

Share 页 SSR iesdouyin.com/share/video

Web API aweme/v1/web/aweme/detail

视频 CDN

图片 CDN

一次解析走完的五步#

从粘贴分享链接到拿到无水印直链,一共五步:

粘贴分享文本

0.20 复制打开抖音...https://v.douyin.com/xxx

提取 itemId

正则 /\d{17,19}/

获取 ttwid 会话

ttwid.bytedance.com/union/register

三级降级解析

策略A→策略B→策略C

URL 清洗

playwm→play 去水印

粘贴分享文本

0.20 复制打开抖音...https://v.douyin.com/xxx

提取 itemId

正则 /\d{17,19}/

获取 ttwid 会话

ttwid.bytedance.com/union/register

三级降级解析

策略A→策略B→策略C

URL 清洗

playwm→play 去水印

第一步:从分享文本里抠出 17 位 itemId#

抖音分享是一段文本,混着 emoji、中文、短链。先用正则抓出 URL,再从 URL 里抠 itemId:

// 从分享文本里找抖音域名 URL
function extractDouyinUrl(text) {
const short = text.match(/https?:\/\/v\.douyin\.com\/[A-Za-z0-9_-]+/);
if (short) return short[0];
const long = text.match(/https?:\/\/(www\.)?(iesdouyin|douyin)\.com\/[^\s"'<>]+/);
if (long) return long[0];
return text.trim();
}
// 从 URL 里抠 17-19 位数字 itemId
function extractItemId(text) {
const m = text.match(/(\d{17,19})/);
return m ? m[1] : '';
}

如果 URL 里直接带数字 ID(如 douyin.com/video/7624888803265880255),一步到位。如果是短链 v.douyin.com/LhvshcYJWPc,先 follow 重定向,再从最终 HTML 里抠。

第二步:申请合法 ttwid 会话#

抖音的 Web API 不再从 Share 页下发 Set-Cookie: ttwid=xxx,直接请求会返回 status_code=11110 encrypt_data_miss。所以要主动调一次 ttwid.bytedance.com 的注册接口:

async function fetchTtwid() {
const resp = await fetch('https://ttwid.bytedance.com/ttwid/union/register/', {
method: 'POST',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...',
'Content-Type': 'application/json',
'Origin': 'https://www.douyin.com',
'Referer': 'https://www.douyin.com/',
},
body: JSON.stringify({
region: 'cn',
aid: 1128,
service: 'www.douyin.com',
app_name: 'aweme_web',
device_platform: 'web',
cbUrlProtocol: 'https',
migrate_source: 0,
needFid: false,
}),
});
// 从 Set-Cookie 里取
const cookies = resp.headers.getSetCookie();
const ttwidCookie = cookies
.map(c => c.match(/ttwid=([^;]+)/))
.find(Boolean);
return ttwidCookie ? ttwidCookie[1] : '';
}

ttwid 官方给的 Max-Age 是 31536000(1 年),但代码里只缓存 24 小时,存在 Worker 实例的内存里,省掉重复申请。

第三步:三级降级解析策略#

拿到 itemId 和 ttwid 后依次尝试三种方式,前一个失败就走下一个。

策略 A:Share 页 SSR 数据提取#

模拟 iPhone Safari UA 访问 https://www.iesdouyin.com/share/video/{itemId},从 HTML 里找 window._ROUTER_DATA。抖音前端用 SSR 把视频数据直接嵌在 <script> 里了:

function parseFromEmbeddedData(html) {
// 找 window._ROUTER_DATA = { ... } 那段 JSON
const rd = extractWindowJson(html, '_ROUTER_DATA');
if (!rd) return result;
// 深度优先搜索,找包含 video.play_addr 或 images 的节点
function findMedia(node, depth = 0) {
if (depth > 20 || !node) return null;
if (node.video?.play_addr?.url_list?.length || node.images?.length) {
return node; // 找到了!
}
for (const k of Object.keys(node)) {
const r = findMedia(node[k], depth + 1);
if (r) return r;
}
return null;
}
const hit = findMedia(rd);
if (hit) return extractFromApiItem(hit);
}
为什么用 DFS

_ROUTER_DATA 嵌套很深,路由配置、组件树、API 响应缓存都塞在里面,视频节点通常在几十层以下,BFS 逐层扫太慢。DFS 命中即返回,运气好第 3 层就结束了。

策略 B:官方 API + ttwid#

Share 页没拿到数据(可能抖音改了 SSR 结构),就带 ttwid Cookie 调 /aweme/v1/web/aweme/detail/:

async function fetchApiWithTtwid(itemId, ttwid) {
// 生成 a_bogus 签名(见下文)
const query = `aweme_id=${itemId}`;
const ab = await generateABogus(query, MOBILE_UA);
const url = `https://www.douyin.com/aweme/v1/web/aweme/detail/?${query}&a_bogus=${ab}`;
const resp = await fetch(url, {
headers: {
'User-Agent': MOBILE_UA,
'Cookie': 'ttwid=' + ttwid,
'Referer': `https://www.douyin.com/video/${itemId}`,
},
});
const data = await resp.json();
const item = data.aweme_detail; // 视频数据在这里
return extractFromApiItem(item);
}

策略 C:裸 API 调用#

连 ttwid 注册都失败了,最后试试不带 Cookie 裸调 iesdouyin 的老接口。大概率被拒,偶尔能成(比如 itemId 缓存还没过期)。

成功

失败

成功

失败

成功

失败

开始

策略A: Share 页 SSR

返回结果

策略B: ttwid + aweme API

策略C: 裸 API

报错: 无法获取媒体资源

成功

失败

成功

失败

成功

失败

开始

策略A: Share 页 SSR

返回结果

策略B: ttwid + aweme API

策略C: 裸 API

报错: 无法获取媒体资源

a_bogus 签名怎么来的#

/aweme/v1/web/aweme/detail/ 要求带 a_bogus 查询参数,这是抖音的防爬签名。它不是加密,是确定性算法:SHA-256 加一个自定义的 4 字混合器。

async function generateABogus(query, ua) {
// Step 1: query + ua + 两个固定盐 → 拼起来 SHA-256
const salted = query + ua + 'W8hD8o3b2wXvQx8n5Gz7a1jY' + 'Kz9x2p1f4vJ8t6sD';
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(salted));
// Step 2: 取 digest 前 16 字节,过两次 _byv 混合
// _byv 是一个自定义的 4 字(16 字节) mixer,用魔数打乱
const ab1 = _byv(digest, 0, 16);
const ab2 = _byv(mixed, 0, 16);
// Step 3: 结果 16 字节自定义 Base64 → 24 字符
return customB64(finalBytes.subarray(0, 16));
}
两个魔数

0x9E3779B9 是黄金比例倒数(2^32 / φ),MurmurHash、FNV 里常见;0xEDB88320 是 CRC-32/IEEE 的多项式,常用来构造字节混淆表。

还有一处绕坑:动态 require 防 esbuild 静态解析。

// 故意用间接引用,避免源码出现 require('crypto') 字面量
// 否则 Cloudflare Pages Functions 的 esbuild 会尝试解析 Node 内置模块
// Workers 运行时里没有 Node 模块,直接报错
function _nodeRequire(name) {
const fn = new Function('return require'); // 动态构造,esbuild 不追踪
return fn()(name);
}

Workers 里有 crypto.subtle,线上走 Web Standard API;本地 Node 调试走 Node crypto。动态 require 让同一份代码两边都能跑。

第四步:从抖音 API 响应里抠出无水印直链#

抖音 API 返回的 JSON 结构很深,但视频/图文的 URL 字段是固定的。extractFromApiItem 做了几层候选回退:

function extractFromApiItem(item) {
// 视频 URL 有四个候选字段,按优先级挑第一个有值的
const candidates = [
item.video.play_addr?.url_list, // 主地址(有水印标记 playwm)
item.video.download_addr?.url_list, // 下载地址(通常无水印)
item.video.play_addr_h264?.url_list, // H264 编码地址
item.video.bit_rate?.[0]?.play_addr?.url_list, // 备用码率
];
for (const arr of candidates) {
if (arr?.length) {
// 去水印!playwm → play
r.playUrl = arr[0].replace(/playwm/g, 'play');
if (r.playUrl.startsWith('http')) break;
}
}
// 图文类型
const imgs = item.images || item.image_list;
if (imgs?.length) {
r.images = imgs.map(i => ({
url: i.url_list?.[0] || i.url,
width: i.width, height: i.height,
}));
r.playUrl = ''; // 有图文就不是视频
}
}

第五步:Referer 防盗链绕过#

视频 CDN 强制校验 Referer,浏览器空 Referer 直连会被 Chrome 的 ORB(Opaque Response Blocking)拦掉,返回 403。绕过去的办法是让 Cloudflare Functions 代理时补上 Referer:

// functions/api/entry.js — handleVideo
const resp = await fetch(videoUrl, {
method: 'GET',
headers: {
'User-Agent': MOBILE_UA,
'Referer': 'https://www.douyin.com/', // ← 关键!抖音 CDN 只看这个
'Accept': '*/*',
'Accept-Encoding': 'identity', // 禁止压缩,透传原始流
// 透传 Range 头,支持拖动进度条和下载续传
},
});
// 返回给前端时,删掉抖音 CDN 加的安全头
// 否则视频在 <video> 标签里也播不了
const outHeaders = new Headers(resp.headers);
outHeaders.delete('content-security-policy');
outHeaders.delete('x-frame-options');
outHeaders.delete('cross-origin-resource-policy');
outHeaders.set('Access-Control-Allow-Origin', '*');
return new Response(resp.body, { status: resp.status, headers: outHeaders });

为什么图片不用代理#

图片 CDN → 接受空 Referer → <img referrerpolicy="no-referrer"> 直连 ✅
视频 CDN → 强制校验 Referer → 必须走 Functions 代理 ❌

结果是图片预览和下载不消耗服务器带宽(浏览器直连抖音 CDN),只有视频走代理。视频是吃带宽的大头,而 Cloudflare Pages Functions 的出站流量不计费。

为什么不依赖第三方解析 API#

多数抖音解析站是套壳:自己不解析,转手调别人的 api.xxx.com/?url=xxx。

维度第三方 API自建
稳定性别人挂了你也挂自己控制
延迟多一跳 → 慢直连抖音 → 快
成本大多付费0(CF 免费额度)
风控API 被抖音封就得换自己改策略
合规性灰色地带同上,但更可控

自建的代价是得搞懂 ttwid、a_bogus、Referer 这套风控逻辑。好在抖音 Web 接口公开且稳定,比逆向 App 那套 X-Gorgon、X-Khronos 加密头简单得多。

对照表#

设计解决什么问题用到的东西
URL → itemId 提取抖音分享文本格式杂乱正则 + 短链重定向
ttwid 会话注册抖音不再从 Share 页下发 Cookiettwid.bytedance.com/union/register
三级降级解析抖音 SSR/API 结构可能变Share 页 → aweme API → 裸 API
a_bogus 签名Web API 防爬虫SHA-256 + 4 字 mixer + 自定义 Base64
playwm → play 替换视频 URL 带水印标记正则替换
Referer 代理视频 CDN 强制校验Cloudflare Functions 中转
图片直连省服务器带宽referrerpolicy="no-referrer"

后端 1000 行,零第三方依赖,跑在 Cloudflare Pages 免费额度上(每天 10 万次 Functions,出站带宽不计费)。抖音哪天改了 SSR 结构,就得跟着调策略,这也是三级降级存在的理由。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

自建抖音无水印解析站原理:不依赖第三方 API 的三级降级架构
https://www.cpdd520.top/posts/dyextract-douyin-no-watermark-parser/
作者
小鼠宝
发布于
2026-09-01
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
小鼠宝
来和我一起打洲吧!
分类
标签
最新动态
站点统计
文章
12
分类
4
标签
41
总字数
13,045
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
EdgeOne Pages
博客版本
Firefly v6.16.7
文章许可
CC BY-NC-SA 4.0
文章目录