CatVodTVSpider开发实战:用Java爬虫为TVBox构建稳定数据源
简介CatVodTVSpider是一份基于Java语言的视频内容爬虫设计源码面向需要批量获取视频数据的开发者和研究者完整覆盖网络请求、数据解析、错误处理等爬虫核心环节。压缩包共101个文件大小约12.53MB包含35个Java源文件、27个JSON文件、8个XML文件、7个JAR包以及Gradle构建脚本、批处理、YML配置、Markdown说明等其中Java源文件实现主要抓取逻辑JSON/XML用于配置与数据组织JAR包提供运行时依赖构建脚本可一键完成编译和打包文件类型完整便于直接观察爬虫项目的工程结构。源码目录结构清晰还附带readme.txt安装运行指南、gradlew.bat与buildAndGenJar.bat自动化脚本可从零搭建环境并复现项目通过settings.gradle、build.gradle等文件还能了解依赖管理与多模块配置方式。已有261人学习适合中高级Java开发者研究爬虫框架设计、配置管理与自动化构建也可作为网络爬虫课程设计或二次开发的参考基座。1. CatVodTVSpider是什么先搞清楚它爬的是哪种数据CatVodTVSpider 是TVBox类播放器数据源体系里那套开源的 Java 爬虫代码它本身不是直接抓全网页面的大规模爬虫而是一组“针对视频站的解释器”把目标站的首页、分类、搜索、详情、播放地址转成播放器能读懂的 JSON。真实场景是盒子或手机上的播放器已经装好不想为换内容源重新打包 APK于是把新写的 Spider 类编译进一个 jar播放器通过固定 HTTP 接口问 jar 要数据jar 再去抓目标站点、清洗、返回结果。适合会 Java 后端、想复用爬虫技术做数据源的人也适合面试前重新梳理抓取、并发、字符串解析这些基础功的读者。需要注意的是播放器请求 jar 时往往带着 UI 等待Spider 的并发和响应时长必须按“弱设备”设计这和跑在机房里的通用爬虫写法不太一样。2. 用Java搭建CatVodTVSpider开发环境与工程骨架2.1 从源码抽骨架Spider接口、请求响应、Runner入口CatVodTVSpider 这类工程通常不是 Spring 单体应用而是一个可以被独立运行的轻量 jar。源码里真正要复刻的只有三层Spider 接口、请求响应封装、Runner 进程入口。站点相关的业务代码全部落在 Spider 实现类里接口设计得足够薄才能做到“一个站点一个类互不污染”。一个常见的最小工程 src 目录结构是src/main/java ├── core/HttpUtils.java ├── core/SpiderReq.java ├── core/SpiderResp.java ├── core/Spider.java ├── runner/Runner.java └── spider/DemoSpider.javaSpider 接口需要覆盖播放器会调用的 5 类操作职责划分如下方法输入输出homeContent站点配置参数首页推荐位和分类的 JSON 文本categoryContent分类 ID 和页码当前分类的视频列表detailContent视频 ids视频详情和播放列表searchContent搜索词搜索结果列表playerContent播放参数真正的播放直链接口返回统一用 String靠 JSON 结构在 jar 和播放器之间传数据这比返回对象再序列化少一层转换也方便后续直接打成可执行 jar。Runner 入口则负责读取命令行参数或 HTTP Body把字符串命令翻译成上述 5 个方法的调用。2.2 pom.xml 与依赖取舍复现这个工程时我一般以 JDK 8 为编译基线因为很多电视端定制的系统版本停留在 Android 6 到 9JDK 8 字节码兼容性最好。Maven 里只保留三个必要依赖JSON 解析、HTTP 客户端、JUnit 做单元测试。JSON 解析建议用 gson 或 fastjson 1.2.83 以上版本老版本 fastjson 有反序列化漏洞本地测试无所谓但打成 jar 分发时要尽量避免引入供应链风险。properties maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties dependencies dependency groupIdcom.google.code.gson/groupId artifactIdgson/artifactId version2.10.1/version /dependency dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version3.12.13/version /dependency /dependenciesgson 2.10.1 和 okhttp 3.12.13 这个组合在 Android 5 上都能跑。okhttp 3.x 的连接池和超时控制比手写 HttpURLConnection 省事而且支持 gzip很多视频站默认开启压缩少了这一步会偶尔拿到乱码响应。如果目标站点走 HTTPS 且证书不标准okhttp 需要加 HostnameVerifier 和信任所有证书的 X509TrustManager只建议在本地调试时放开打正式包时还是要回到系统证书验证。2.3 最小SpiderReq/SpiderResp模型SpiderReq 和 SpiderResp 要尽量薄只做数据和容器不掺业务逻辑。SpiderReq 至少应该包含 URL、请求头、请求参数三部分SpiderResp 至少包含最终 URL、响应体、响应 Content-Type 三部分。public class SpiderReq { private String url; private MapString, String header new HashMap(); private MapString, String params new HashMap(); public SpiderReq(String url) { this.url url; this.header.put(User-Agent, Mozilla/5.0); } public String getUrl() { return url; } public void setUrl(String url) { this.url url; } public MapString, String getHeader() { return header; } public MapString, String getParams() { return params; } } public class SpiderResp { private String url; private byte[] body; private String contentType; public String getUrl() { return url; } public void setUrl(String url) { this.url url; } public byte[] getBody() { return body; } public void setBody(byte[] body) { this.body body; } public String getContentType() { return contentType; } public void setContentType(String contentType) { this.contentType contentType; } }这里把响应体设计成 byte[] 而不是 String是为了兼容 GBK、GB2312 这类非 UTF-8 编码的站点。如果 HttpUtils 在读取时已经按 UTF-8 转成 String再遇到 GBK 页面就只能得到乱码所以更稳妥的做法是原始字节留在 SpiderResp 里由每个 Spider 自己决定解码方式。header 和 params 用两个独立 Map是为了让播放器传过来的参数原样透传又能追加站点自己需要的固定参数。2.4 Runner入口如何把命令变成JSONRunner 不需要引入 Spring Boot一个 main 方法加一个 switch 足够。常见做法是播放器或本地调试脚本向 jar 传一段简短命令例如home:uid1Runner 解析冒号前的操作名再解析冒号后的键值对最后调用对应 Spider 方法并把结果打印到标准输出。public class Runner { public static void main(String[] args) throws Exception { if (args.length 1) { return; } String[] parts args[0].split(:, 2); String op parts[0]; MapString, String params parseParams(parts.length 1 ? parts[1] : ); Spider spider new DemoSpider(); switch (op) { case home: System.out.println(spider.homeContent(params)); break; case category: System.out.println(spider.categoryContent(params)); break; case detail: System.out.println(spider.detailContent(params)); break; case search: System.out.println(spider.searchContent(params)); break; default: break; } } private static MapString, String parseParams(String s) { MapString, String map new HashMap(); for (String item : s.split()) { String[] kv item.split(, 2); if (kv.length 2) { map.put(kv[0], kv[1]); } } return map; } }这个 Runner 的优点是调试简单java -jar spider.jar home:uid1就能在命令行看到完整 JSON不用先启一个 Web 服务。真正的播放器场景里jar 会被包装成 HTTP 服务但那层包装和 Spider 逻辑无关单独放一个 jar 模块即可。parseParams 用了split(, 2)而不是split()是防止参数值里带等号时被切碎。3. 手写一个Spider类解析JSON、拼装SpiderReq/SpiderResp3.1 观察目标站返回结构决定走 JSON 还是正则拿到一个目标站我一般先不发请求写代码而是用 curl 把首页和详情页的原始响应各拉一份下来看结构。JSON 接口优先走 JSONPath 或 gson 解析HTML 页面才考虑正则提取因为视频站 HTML 改版频繁正则在字段顺序变化后很容易失效。下面的命令只做结构观察不涉及任何页面内容curl -s -H User-Agent: Mozilla/5.0 \ https://api.example.com/api.php?acdetailt4pg1 \ | head -c 800 | jq .如果响应以{或[开头说明是 JSON 接口如果返回的是html就要去看容器结构。网站真实返回通常还带vod_name、vod_pic、vod_play_url这类字段但要注意有些站点把关键字段放在嵌套的对象里而不是平铺在顶层。此时用 gson 逐层读取即可没必要引入完整 JSONPath 库。Content-Type 是text/html但 Body 实际是 JSON 的站点我也见过所以判断标准以首字符为准不能只看头。3.2 最小可跑的 DemoSpider 模板Spider 实现类要遵循“不持有业务状态”的原则尽量不做成员变量缓存。因为播放器可能同时创建多个 Spider 实例实例里的 HashMap、ArrayList 一旦被并发读写会出现要么数据串台、要么 ConcurrentModificationException 的问题。下面的模板只做一件事拼请求、发请求、解析 JSON、输出统一结构。public class DemoSpider implements Spider { private static final String UA Mozilla/5.0 (Linux; Android 9); private static final String HOME_API https://api.example.com/api.php; Override public String homeContent(MapString, String params) { SpiderReq req new SpiderReq(HOME_API); req.getHeader().put(User-Agent, UA); req.getParams().put(ac, list); req.getParams().putAll(params); try { SpiderResp resp HttpUtils.get(req); JSONObject data parseJson(resp); JSONObject result new JSONObject(true); result.put(class, parseClasses(data.getJSONArray(classes))); result.put(list, parseVideos(data.getJSONArray(videos))); return result.toJSONString(); } catch (Exception e) { return {}; } } private JSONObject parseJson(SpiderResp resp) throws Exception { String text new String(resp.getBody(), UTF-8); if (resp.getContentType() ! null resp.getContentType().toLowerCase().contains(gb)) { text new String(resp.getBody(), GBK); } return JSON.parseObject(text); } }这里的 http 请求和 JSON 解析都抽到了 HttpUtils 与 parseJson 里Spider 只管业务映射。new JSONObject(true)是 fastjson 按序输出便于比对播放器端的字段顺序如果换 gson 则用LinkedTreeMap保持顺序。catch 里返回空 JSON 而不是 null是避免 Runner 打印出 null 字符串播放器收到空对象后至少会走一次空数据处理不会直接崩溃。3.3 JSON字段映射与编码转换播放器对数据源有约定俗成的字段名站点返回的字段名和这些约定名往往不一样映射逻辑是 Spider 类里最容易出错的部分。以列表到视频卡片的映射为例站点字段Spider输出字段说明name / titlevod_name列表卡片标题pic / thumbvod_pic视频封面相对路径要拼站根域名url / idvod_id详情页 ID供 detail 方法使用play_url / m3u8vod_play_url播放地址多集用分隔符拼接很多站点返回的图片地址是相对路径比如/upload/vod/20240101.jpg如果直接塞进 vod_pic播放器会请求一个打不开的地址。常见做法是在清洗字段时判断前缀以//开头就补协议头以/开头就拼站点根域名private String toAbsUrl(String raw, String base) { if (raw null || raw.isEmpty()) { return ; } if (raw.startsWith(http://) || raw.startsWith(https://)) { return raw; } if (raw.startsWith(//)) { return https: raw; } return base raw; }编码转换的坑更多是隐性的站点声明Content-Type: text/html; charsetgb2312但播放器请求时带了Accept-Encoding: gzipokhttp 会自动解压 Body此时 charset 还在头里但 Body 已经被解压成原始字节所以必须在解压后、转 String 前拿到编码。我在 3.2 里的 parseJson 从 Content-Type 里判断是否含gb命中就按 GBK 解码这样比统一 UTF-8 稳得多。3.4 调试顺序本地先验证三个方法写新站点时不要直接打包丢给播放器那样一旦出错只有播放器日志里的一行超时提示定位很痛苦。我一般先把 Runner 跑起来按 home、detail、play 的顺序逐个验证每个方法都先看原始 JSON再看映射后的 JSON。下面这条命令就是本地验证 detail 的典型方式mvn -q clean package java -jar target/spider.jar detail:ids12345返回的 JSON 里如果 vod_play_url 为空先回去看原始响应里播放地址是不是需要二次请求如果 vod_play_from 有值但 vod_play_url 没值多半是播放地址字段名映射错了。这个阶段不要开并发单线程把链路打通后面第 4 章的并发设计才有意义。4. 并发与防抖设计保证CatVodTVSpider在播放器里稳定跑4.1 播放器的并发请求模型播放器打开首页、切换分类、进入详情页时会对 jar 发出多个请求。首页可能有 3 个推荐位分类页可能有 5 个 Tab一次操作触发 5 到 10 个 HTTP 请求很常见。但播放器所在的电视盒子内存通常只有几百 MB 到 2 GBCPU 也远不如手机jar 内部如果每来一个请求就 new 一个线程盒子会卡顿甚至被系统杀掉。并发设计要回答的不是“能开多少线程”而是“同一时刻最多让多少个请求出去”。下面是经过多台弱设备测试后我常用的并发参数参数推荐值理由corePoolSize1平时只有零星请求没必要常驻多线程maximumPoolSize4分类页多个 Tab 同时拉取时能并行keepAliveTime30 秒空闲线程快速释放队列容量64防止请求堆积拖垮 jar 进程maximumPoolSize 超过 6 之后盒子端收益很小反而会让目标站点更快触发限流所以一味的并发度提升在这里不是一个好方案。4.2 线程池参数不必过大线程池用 ThreadPoolExecutor 直接构造Executors.newFixedThreadPool 虽然写着省事但内部用的无界队列在请求积压时会吃掉大量内存移动端场景必须显式设置队列长度。ExecutorService pool new ThreadPoolExecutor( 1, 4, 30L, TimeUnit.SECONDS, new LinkedBlockingQueue(64), Executors.defaultThreadFactory(), new ThreadPoolExecutor.CallerRunsPolicy() );CallerRunsPolicy 的作用是当线程池和队列都满时不让请求被丢弃而是由调用方线程直接执行。对播放器来说最坏结果是请求变慢但不会无声无息地消失。如果换 DiscardOldestPolicy播放器会偶发拿不到数据排查时又看不到异常反而更难定位。这里的 corePoolSize 设 1 不是节省那一个线程而是让低峰期线程数保持最低真正起量时靠队列触达 maximumPoolSize。4.3 对同一URL做每秒防抖播放器切换分类时同一个 URL 可能在一秒内被请求两次。这种重复请求既浪费盒子带宽也增加了被打断的风险。常见做法是对相同 key 的请求做时间窗去重把一秒内的重复 URL 直接拦截。public class RequestDebouncer { private final ConcurrentHashMapString, Long stamp new ConcurrentHashMap(); public boolean isDuplicate(String key, long nowMs) { Long old stamp.putIfAbsent(key, nowMs); if (old null) { return false; } if (nowMs - old 1000L) { return true; } stamp.put(key, nowMs); return false; } }这个防抖的 key 最好由URL 参数拼接而成只拼 URL 会误伤同一页面不同页码的请求。窗口设置为 1000 毫秒是因为播放器的分类 Tab 通常不会在 1 秒内切换两次而正常的翻页间隔一定大于这个值。stamp 表需要控制大小超过 2048 个 key 时做一次整体清理否则长时间运行的内存增长也会成为问题。4.4 Cookie与会话状态分离很多视频站的详情接口需要登录态而 Spider 实现类往往被设计成无状态的Cookie 不能存进成员变量。正确做法是把 Cookie 放到请求头里随 SpiderReq 传入每次都携带同一个 Cookie 字符串但不在对象内部持有可变状态。req.getHeader().put(Cookie, uid10086; tokenabc123; expire1810000000);如果目标站点会在响应头里种新 Cookie可以把这个响应头回填到请求头中但只限定在当前请求上下文里不要污染其他 Spider。常见误用是把 Cookie 放进静态变量导致多站点共用一个 jar 时互相串身份。动态 Cookie 的保存位置应当是播放器自己的存储层Spider 只负责透传。4.5 Java 这种线程池设计的取舍网上经常看到并发方案之争单就 CatVodTVSpider 的场景Java 线程池和 Python asyncio 是两种路线。Java 的优势在于线程池由 JVM 统一管理垃圾回收、连接池、队列都在一个进程内适合这种请求量不大但要求稳定常驻的程序。Python 爬虫生态里 requests 上手快但异步代码里一旦混入同步阻塞调用整个事件循环都会被卡住这在播放器低配环境下更难容忍。线程池不是 Java 八股文里背完就完的参数题这里的取舍是由“弱设备、短并发、长驻内存”三个条件决定的。把这套逻辑讲清楚比记住 corePoolSize 默认值有用得多。5. 源码调试与验证技巧抓包比对、压测、批量校验5.1 给Spider包一个命令行校验器我习惯在工程里单独留一个 CheckSpider 类它不参与 jar 的正常逻辑只用来做数据源上线前的体检。这个类接收home、detail、search三个指令分别触发对应方法并把结果写入临时文件方便人工确认。public class CheckSpider { public static void main(String[] args) throws Exception { String op args[0]; MapString, String params new HashMap(); DemoSpider spider new DemoSpider(); String out ; switch (op) { case home: out spider.homeContent(params); break; case detail: params.put(ids, args[1]); out spider.detailContent(params); break; case search: params.put(key, args[1]); out spider.searchContent(params); break; default: break; } Files.write(Paths.get(op .json), out.getBytes(UTF-8)); } }运行java -cp spider.jar CheckSpider detail 12345后打开 detail.json 检查 vod_play_url 是否包含m3u8、mp4或目标站点规定的播放格式这一步就能拦截大部分字段映射问题。5.2 用 jstack 定位卡住的HTTP线程如果播放器频繁转圈先看 jstack。命令是jstack pid重点看http-bio和pool-前缀的线程栈如果大量线程停在SocketInputStream.read说明是连接池里的连接被对端关闭但没有即时失效此时把 okhttp 的retryOnConnectionFailure(true)打开或者缩短读超时到 10 秒比改业务代码更快见效。如果是线程停在LinkedBlockingQueue.take则是线程池核心线程长期空闲说明 corePoolSize 设得偏大了。5.3 按“字段完整率、耗时、异常数”批量验证单个接口通只能证明没报错不代表字段质量达标。批量验证时我按下面这张表的阈值判断站点能不能上线指标建议阈值验证方法home 返回字段完整率100%检查 class 和 list 是否为空detail 平均耗时小于 3 秒在 HttpUtils 里埋耗时日志播放地址可解析率大于 95%取 50 个视频逐个请求播放接口异常请求占比小于 2%失败次数除以总请求数播放地址可解析率最容易不达标因为有些站点对少量视频没有做转码播放器拿到的是一个空壳地址。用脚本跑全量 50 个样本时只要累计异常超过 3 个就该回退到 4.3 的防抖窗口再确认一次排掉重复请求造成的假异常。如果某一天用户反馈分类页一直转圈把 4.3 的缓存窗口调大到 1.5 秒再试问题往往不在代码逻辑里而是响应耗时中位数超过 2 秒后需要回到请求头、gzip、连接池三个地方排查。并发参数先按 1 核心线程、4 最大线程、1 秒防抖跑一轮再逐步扩大通常就能把不稳定的站点拦在上线之前。本文还有配套的精品资源点击获取