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

这是什么
一个跑在 Cloudflare Pages 免费额度上的抖音无水印解析工具。整站没有调用任何第三方解析服务,直接请求抖音官方接口,用三级降级策略应对风控变化。
前端纯 HTML + 原生 JS → Cloudflare Pages Functions 后端 → 抖音官方 API后端 1000 行出头,devDependency 只有 wrangler 一个。没有服务器,没有数据库,也没有 Puppeteer 那类无头浏览器。
整体架构
一次解析走完的五步
从粘贴分享链接到拿到无水印直链,一共五步:
第一步:从分享文本里抠出 17 位 itemId
抖音分享是一段文本,混着 emoji、中文、短链。先用正则抓出 URL,再从 URL 里抠 itemId:
// 从分享文本里找抖音域名 URLfunction 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 位数字 itemIdfunction 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);}_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_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 — handleVideoconst 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 页下发 Cookie | ttwid.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 结构,就得跟着调策略,这也是三级降级存在的理由。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!














