
最近一段时间隔三差五就有人来问我用 Java 怎么拿抖音无水印视频和背景音乐有人是想做个人素材库有人想把喜欢的 BGM 单独存下来还有人接了私活要做下载工具。这个问题说难吧网上相关代码到处都是说简单吧照着老教程跑往往一头雾水因为页面结构和接口早就换过好几轮了。今天不卖关子把整套解析思路、能跑的代码片段、以及我实际踩过的坑一次性讲清楚适合刚接触网页解析、想自己动手写下载工具的同学也适合只想搞清楚原理再封装成工具类的老手。1. 先搞清楚无水印视频到底藏在哪一步很多人的第一反应是“直接抓接口”。但分享链接本身不是一个直接的视频地址它更像一把钥匙带着你一步步走到真正的视频资源。整个链路大概是这样的用户从 App 里复制分享口令 → 口令里包含一个短链接 → 短链接会经过多次 302 跳转到达视频详情页 → 详情页 HTML 里嵌着一份页面渲染数据 → 渲染数据里包含了视频播放地址、音乐地址、作者信息等 → 播放地址默认带水印参数替换掉就得到无水印版本。所以整个解析过程的核心就是把分享口令还原成视频详情页再从详情页的数据里把资源地址挖出来最后下载保存。这里要特别强调一个概念有水印和无水印的地址差异。平台页面里的播放地址通常会带水印相关的标识常见的有两种情况一是在 URL 路径里出现playwm二是在查询参数里出现watermark1。当你把playwm替换成play、把watermark1改成watermark0之后拿到的往往就是无水印的原片地址。当然这不是百分百的规则不同时期、不同视频格式会有差异但绝大多数场景下这个规律是成立的。背景音乐也是同理。视频详情数据里会有一个音乐对象里面包含了歌曲名、作者名和音频播放地址这个地址本身就是无水印音频不需要额外处理直接下载就是一首干净的 BGM。还有一个点容易被忽略播放地址是有时效性的。解析出来之后不能存起来等以后再用最好立刻下载否则过一段时间地址会失效。这是我在实际项目里反复遇到的坑后面会细说。2. 环境准备与工程骨架用到的库和模块怎么规划做这个解析项目不需要什么重型框架一个普通的 Java 工程就够。我建议用 Java 11 以上的版本因为 JDK 自带的java.net.http.HttpClient已经很好用了不需要额外引入 Apache HttpClient 这类依赖能少一个是一个。Maven 里只需要两个第三方库dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.17.2/version /dependency dependency groupIdcom.alibaba/groupId artifactIdfastjson/artifactId version2.0.51/version /dependencyjsoup负责解析 HTML定位页面里的script标签fastjson负责处理 JSON 数据因为页面数据是一个很深的嵌套结构用 JSON 库处理比正则字符串截取靠谱得多。工程模块我建议分成四个类职责非常清晰别全堆在一个类里不然维护到后面会想骂人。类名职责HttpUtil封装 GET 请求、重定向跟随、请求头设置DouYinParser分享链接解析、视频 ID 提取、页面数据抽取Downloader文件下载、超时重试、内容类型校验Main命令行入口串起整个流程请求头是在一开始就要想好的东西。核心就两个User-Agent必须是一个真实浏览器的 UA不能是 Java 默认的Java/xx否则平台很容易识别为爬虫Referer在两步用到请求视频详情页的时候带https://www.douyin.com/下载媒体文件的时候带视频页地址或者站内地址不然资源服务器会拒绝访问。我在写这类解析工具时习惯把所有请求头都收敛到HttpUtil里所有请求都统一从这里发出去。这样做的好处是一旦平台更新了校验逻辑我只需要改一个文件而不是满项目找哪里漏了头。3. 核心实现从分享口令到视频文件落地的完整流程下面直接上代码。我会按实际处理的顺序一步步来每一段都会解释为什么这么写。3.1 第一步还原真实视频页地址用户从 App 复制出来的通常是一段很长的文字中间夹着一条以https://v.douyin.com/xxxx/开头的短链接甚至可能是多条链接混在一起。所以第一步是用正则把短链接抠出来。Pattern shareUrlPattern Pattern.compile(https?://v\\.douyin\com/[\\w\\-]); Matcher matcher shareUrlPattern.matcher(shareText); if (matcher.find()) { String shortUrl matcher.group(); String realPageUrl HttpUtil.followRedirect(shortUrl); System.out.println(视频页地址: realPageUrl); }这段代码里的followRedirect方法是整个链路的第一步public class HttpUtil { private static final String UA Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36; public static String followRedirect(String url) throws Exception { HttpClient client HttpClient.newBuilder() .followRedirects(HttpClient.Redirect.ALWAYS) .connectTimeout(Duration.ofSeconds(15)) .build(); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(url)) .header(User-Agent, UA) .GET() .build(); HttpResponseString response client.send(request, HttpResponse.BodyHandlers.ofString()); return response.uri().toString(); } }这里的关键在于HttpClient.Redirect.ALWAYS。短链接会跳很多次最终落到的地址才是真正的详情页地址。Java 的HttpClient在跟随重定向时会直接把最终的 URI 暴露在response.uri()里不用自己一层层解 302省了很多事。还有一点需要注意分享链接跳转后的地址不一定永远是video/视频ID这种格式也有可能是note/数字后者通常对应图文合集。想做视频下载的话遇到note/前缀要先判断内容是不是包含视频避免解析到纯图文数据时报错。3.2 第二步从页面 HTML 里提取视频 ID 和基础数据拿到真实的视频页地址之后下一步就是抓取这个页面的 HTML把嵌在里面的数据抠出来。Document doc Jsoup.connect(pageUrl) .userAgent(HttpUtil.UA) .referrer(https://www.douyin.com/) .timeout(15000) .get(); Elements scripts doc.select(script); String rawJson null; for (Element script : scripts) { String text script.html(); if (text.contains(_ROUTER_DATA) || text.contains(RENDER_DATA)) { int start text.indexOf({); int end text.lastIndexOf(}); rawJson Parser.unescapeEntities(text.substring(start, end 1), false); break; } } if (rawJson null) { throw new RuntimeException(页面结构有变化未找到渲染数据); }很多人会忽略一个细节script标签里的内容可能被 HTML 实体转义过比如quot;、\u003d这类的字符。直接用substring截出来的 JSON 字符串拿去解析十有八九会报错。这里用了Parser.unescapeEntities把实体转换回正常字符是个很实用的经验。从 URL 里提取视频 ID 可以在这里顺带做掉虽然前端重定向地址已经给了 ID但额外补一次更稳private static final Pattern VIDEO_ID_PATTERN Pattern.compile((?:video|note)/(\\d{15,21})); public static String extractVideoId(String pageUrl) { Matcher matcher VIDEO_ID_PATTERN.matcher(pageUrl); if (matcher.find()) { return matcher.group(1); } throw new RuntimeException(无法从页面地址中提取视频 ID); }纯数字 ID 一般是 15 到 21 位正则写得稍微宽一点可以兼容未来的长度变化。3.3 第三步定位无水印播放地址现在到了最核心的一步从渲染数据的嵌套 JSON 里找到视频播放地址。这里的结构非常深字段名在不同场景下还不太一样有的地方叫play_addr有的叫playAddr。与其写死一堆getJSONObject(xxx)的路径不如写一个通用的递归查找方法按字段名去遍历整棵树。private static void findKey(JSONObject obj, String targetKey, ListJSONObject result) { for (String key : obj.keySet()) { Object value obj.get(key); if (key.equals(targetKey) value instanceof JSONObject) { result.add((JSONObject) value); } else if (value instanceof JSONObject) { findKey((JSONObject) value, targetKey, result); } else if (value instanceof JSONArray) { for (Object item : (JSONArray) value) { if (item instanceof JSONObject) { findKey((JSONObject) item, targetKey, result); } } } } }这个方法的作用是在整棵 JSON 树里找到所有目标 key 对应的对象。比如找play_addr就会把所有视频对象、音乐对象里符合条件的数据都捞出来避免因为数据结构封装层级变化而失灵。拿到之后进行地址替换ListJSONObject playAddrList new ArrayList(); findKey(root, play_addr, playAddrList); String noWatermarkUrl null; for (JSONObject playAddr : playAddrList) { JSONArray urlList playAddr.getJSONArray(url_list); if (urlList ! null !urlList.isEmpty()) { String playUrl urlList.getString(0); noWatermarkUrl playUrl .replace(playwm, play) .replace(watermark1, watermark0); break; } }这里有个经验url_list数组里通常会有多个地址第一个不一定总是最稳定的但绝大多数情况能用。如果第一个地址下载失败可以循环尝试后续的地址。拿到无水印地址后我强烈建议先发一个 HEAD 请求确认 Content-Type 是不是video/mp4之类的媒体类型再做实际下载。有些时候地址替换后虽然格式合法但服务端会返回一个 JSON 错误信息直接下载下来是一段文本白占空间还浪费流量。3.4 第四步下载视频文件和背景音乐下载这一步最关键的两个点是带 Referer和校验 Content-Type。不带 Referer 的话媒体服务器经常直接返回 403校验 Content-Type 是为了避免把错误页面当成媒体文件存下来。public static void download(String fileUrl, String savePath, String referer) throws Exception { if (referer null) { referer https://www.douyin.com/; } HttpClient client HttpClient.newBuilder() .followRedirects(HttpClient.Redirect.ALWAYS) .connectTimeout(Duration.ofSeconds(20)) .build(); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(fileUrl)) .header(User-Agent, UA) .header(Referer, referer) .GET() .build(); HttpResponsebyte[] response client.send(request, HttpResponse.BodyHandlers.ofByteArray()); String contentType response.headers().firstValue(Content-Type).orElse(); if (!contentType.startsWith(video/) !contentType.contains(octet-stream)) { throw new RuntimeException(非媒体文件可能是接口返回了错误页面: contentType); } Files.write(Path.of(savePath), response.body()); }背景音乐的地址也在同一个渲染数据里字段结构大概是这样有一个music对象里面有title、author、play_urlplay_url下面又是一个url_list数组。用前面那个findKey方法直接找music对象就行。找到之后取音频地址和基本信息拼一个合法的文件名String bgmTitle music.getString(title); String bgmAuthor music.getString(author); JSONObject playUrlObj music.getJSONObject(play_url); String bgmUrl playUrlObj.getJSONArray(url_list).getString(0); String fileName bgmTitle - bgmAuthor .mp3; fileName fileName.replaceAll([\\\\/:*?\|], _); Downloader.download(bgmUrl, download/ fileName, null);文件名的处理我是特意加了过滤的。Windows 下文件名不能包含\/:*?|这些字符而歌曲名里出现书名号、冒号的概率太高了不处理的话保存时会直接报错。4. 实际项目里的坑请求头、签名校验、频率限制代码跑通只是第一步真正让人头疼的是上线到“批量下载”这个阶段遇到的各种反爬问题。我把自己踩过的坑按严重程度排个序。4.1 浏览器请求头不能省也不能乱造最基础的 UA 不设置会直接收到一个验证页面HTML 里全是乱码或者一段反爬提示。这个好解决用一个最新版 Chrome 的 UA 就行。最隐蔽的是Referer。视频详情页的 HTML 可以不带 Referer 抓但下载媒体文件时如果不带或者带错了来源资源服务器大概率返回 403。我自己遇到过一种情况同一个视频文件地址Referer 用视频页地址能下载用站内首页反而 403。所以下载时的 Referer 最好直接用详情页地址别偷懒统一写首页。另外 Cookie 也要留个心眼。页面数据里的视频基本信息一般不用登录就能拿到但某些视频可能因为在审核期、作者设置了隐私等原因需要 Cookie 才能看到完整数据。我的解决办法是先手动用浏览器访问一次视频页把 Cookie 复制出来在代码里作为可选的请求头参数加上。这不是必须的但是加了这个变量遇到问题时有地方能调。4.2 签名参数与请求频率的现实约束这几年平台明显加强了对关键接口的保护。以前那种直接调某个接口传个item_ids就能拿数据的玩法早就失效了现在很多接口都要求签名参数而这个签名是经过专门算法计算的会绑定请求设备、时间戳、视频 ID 等一堆信息。那为什么前面那套页面解析的方法还能用呢因为页面渲染数据是服务端直接输出在 HTML 里的属于“打开页面就能看到的数据”不需要额外签名。只要平台没有把这块数据也变成异步接口这条路就一直通着。但要注意页面数据不受签名限制不代表你可以肆无忌惮地高频访问。平台还有更底层的风控逻辑短时间大量请求同一个页面很快会触发验证码甚至 IP 限制。我做过一个批量缓存工具按视频 ID 去重之后串行解析每条视频之间加了两秒随机延迟跑了大几百条数据没出问题。但如果把延迟改成并发 20 个线程去抓不到五分钟就开始出验证页了。不要轻易去研究签名逆向。那既违反平台规则又费时费力而且算法一升级你之前的成果全部作废。做个人小工具保持低频、克制地使用现有公开数据方式和频率都在合理范围内才是长期可维护的路子。4.3 页面结构变动带来的维护成本“最新获取”这个说法的背后其实是页面结构一直在变。早期版本的数据嵌在script里的位置比较浅直接用JSONPath就能定位到现在变成了很深的嵌套结构写死路径的方式特别容易崩。我在维护这类解析工具时总结了一套应对结构变动的方法写一个“字段摘要”打印函数把当前 JSON 里的顶层 key 全部打出来快速判断页面结构是不是又换了。不要死磕一个字段名play_addr、playAddr、playAddress这些变体都通过递归找 key 的方式兼容掉。分享链接的跳转逻辑也在变有的短链接会先跳到一个share/前缀的中间页然后再跳到详情页。正则在提取时尽量只匹配https://v.douyin.com/开头这一小段后面的路径变化不影响正则生效。还有一个很实际的经验把解析过的视频 ID 做本地缓存。同一个 ID 重复解析不仅浪费时间还会增加被风控盯上的概率。下载需求和爆款视频往往是高度重复的缓存机制能让整个工具的运行效率高一个量级。5. 合规使用与个人经验建议写代码只是技能问题怎么用才是真正体现判断力的地方。我接触过不少人拿类似工具做批量下载然后把无水印视频二次分发到其他平台甚至做成付费资源站。这种行为既侵害了内容创作者的利益也违反了平台规则还可能给自己惹上麻烦。我认为合理的用途是个人收藏喜欢的视频、给剪辑工作留素材、提取背景音乐做非商业性质的创作参考。用技术解决问题本身没有错但一定要控制使用边界不能把别人的内容当成自己的资源去牟利。最后再分享几个开发上的建议都是我实际用下来的体会第一解析成功后尽快下载不要存地址。无水印播放地址时效性不强但也不是永久的拖久了再消费很容易拿到失效的 403 响应。第二下载文件名别直接拿标题用一定要过滤掉非法字符最好在文件名最后加上视频 ID 或时间戳防止不同视频标题相同导致覆盖。第三任何下载工具都要有超时和重试机制。媒体服务器偶尔抽风是常态一次失败不代表永久失败重试两三次基本能解决大部分问题。第四遇到解析失败不要急着怀疑代码先手动用浏览器访问一遍视频页看看是链接失效、视频删除还是页面结构变化判断清楚再动手改代码。这个习惯帮我省下了大量无效调试时间。整个项目跑完之后你会发现这类“解析下载”的工具没有想象中神秘核心其实就是处理短链接跳转、定位页面数据、递归找资源地址三个环节。哪天接口或者页面结构又变了按这三步重新梳理一遍很快就能恢复战斗力。