简介anime-generator 是一款基于 Java 的动漫链接批量生成工具主要面向需要从 Danbooru 图库收集图片链接的动漫下载爱好者以及想了解命令行程序开发的 Java 入门者能够替代手动查找、复制和编号的繁琐过程。项目压缩包体积约 65KB共 17 个文件核心包括 4 个 Java 源文件、Gradle 构建脚本与封装器、若干 XML 工程配置、说明文档以及可直接运行的 JAR 包其中 Gradle 文件负责依赖与构建任务XML 多为 IDE 工程配置整体结构简洁便于直接导入开发环境或执行构建。通过命令即可控制生成链接的数量、输出文件名并可选传入单一标签进行过滤程序运行后会产生按 ID 递增排序的 JSON 链接文件方便后续脚本或下载工具直接解析使用对于需要批量获取动漫图片入口的用户尤其实用。源码采用 Gradle 组织包含 src/main 标准目录、build.gradle 与 gradlew 包装器读者既能直接运行现成 JAR也能修改代码后重新构建并可就改进点提交 Pull Request资源已有 224 人学习下载适合学习 Java 参数解析、JSON 数据生成和 Gradle 项目配置等知识点是一份兼具实用性与学习价值的参考样例。1. 一个链接整理小工具anime-generator 到底在解决什么问题排查了半天才发现问题是书签里的那些番剧链接早就失效了。四五十个链接逐个点开验证太费劲于是就有了 anime-generator一个用 Java 写的命令行小工具给它一个专题页 URL 或关键词它会自动抓取页面里的所有动漫详情链接清洗去重后输出成一份 Markdown 清单。它解决的是整理链接这件事——把散落在导航页、索引页里的番剧链接批量生成出来交给下载器或收藏夹。适合经常手动抄链接的人以及想快速搭建自己片单索引的开发者。技术实现不算复杂Jsoup 解析 HTML、多线程抓取、规则过滤核心代码加起来不到五百行。2. 架构设计从专题页 URL 到 Markdown 清单的一次完整链路2.1 整体模块划分这个项目的定位是链路短、可扩展所以没有做成 Web 服务而是一个标准的三层命令行程序入口层负责读参数、组装任务核心层负责抓取、解析、过滤输出层负责把结果落盘。为了让后续加新站点、新格式时不至于重写核心层全部面向接口编程。入口层只有一个主类AnimeGeneratorApplication做的事情就是解析命令行参数然后调用LinkGenerator这个门面。门面层是核心设计的重点对上层屏蔽了 Jsoup 的连接细节、线程池的调度细节、过滤规则的执行细节对下层只暴露三个接口——SourceConnector负责连接页面、LinkParser负责提取链接、OutputWriter负责写结果。2.2 数据模型与目录结构项目里最核心的数据结构是一个不可变的AnimeLinkrecord字段包括番剧名、详情页 URL、来源页面 URL 和发现时间。之所以用 record 而不是写一堆 getter是因为这个对象在整个生命周期里不需要被修改不可变类型在多线程抓取时更安全不会出现并发修改同一对象的问题。整个工程是标准 Maven 布局源码结构如下路径职责core/LinkGenerator.java门面编排抓取→解析→过滤→输出全流程core/LinkParser.java用 Jsoup 解析 HTML提取详情页链接core/ResultFilter.java去重、剔除导航页和静态资源链接source/AbstractSourceConnector.java抽象连接器内置超时、重试、UA 处理source/DefaultSourceConnector.java默认实现兼容大多数静态索引页output/MarkdownOutputWriter.java把结果写成带表格的 Markdown 文件output/ConsoleOutputWriter.java直接打印到控制台方便调试resources/config.properties线程数、超时时间、UA 等可配置项2.3 链接生成的完整链路我一般会按发现索引页 → 并发抓详情页 → 过滤去重 → 排序输出四步来理解这个程序。用户给一个专题页 URL程序先把专题页拉下来从里面发现所有指向番剧详情页的链接再去掉带有?from、#comment这类无效参数的条目最后按番剧名排序写入 Markdown。链路里有一个容易被忽略的点DefaultSourceConnector和LinkParser是解耦的。连接器只负责把远程 HTML 变成Document对象解析器只负责从Document里提取链接。这样如果某个站点需要登录 cookie只需要替换连接器如果站点改版了 DOM 结构只需要换解析器。后续的二次开发章节里我会具体演示怎么扩展这两个接口。3. 核心代码拆解解析器、过滤器与多线程抓取的实现细节3.1 主入口与命令行参数解析主入口的代码非常直白。我用的方案是手写一个极简的参数解析器而不是引入 picocli 或 commons-cli。原因很简单这个工具只有四个参数引入一个框架反而让新手看不懂启动流程。看下面这段代码public class AnimeGeneratorApplication { public static void main(String[] args) throws Exception { // 最少需要 --url 参数其余都有默认值 Options options parseArgs(args); if (options.url null) { System.err.println(用法: java -jar anime-generator.jar --url 页面URL); System.err.println(可选参数: --out list.md --threads 4 --timeout 8000); System.exit(1); } LinkGenerator generator new LinkGenerator(); generator.setThreadPoolSize(options.threads); generator.setTimeoutMs(options.timeout); ListAnimeLink links generator.generate(options.url); OutputWriter writer choices(options.out); writer.write(links); System.out.printf(完成解析到 %d 条链接已写入 %s%n, links.size(), options.out); } private static Options parseArgs(String[] args) { Options opts new Options(); opts.out anime-list.md; opts.threads 4; opts.timeout 8000; for (int i 0; i args.length; i) { switch (args[i]) { case --url - opts.url args[i]; case --out - opts.out args[i]; case --threads - opts.threads Integer.parseInt(args[i]); case --timeout - opts.timeout Integer.parseInt(args[i]); } } return opts; } }这段代码的逻辑核心是generate()方法——它不接受任何输出格式参数只返回ListAnimeLink把生成链接和写出链接分开了。这意味着你在测试的时候可以完全不关心输出文件直接在代码里接住 List 做断言。参数说明--threads控制并发抓取线程数默认 4如果你目标站点响应慢可以降到 2--timeout是单次连接超时毫秒数默认 8000遇到慢站可以调大但建议不要超过 15000否则线程池容易被慢请求占满。3.2 链接解析器Jsoup 提取详情页链接解析器是项目里最关键的部分。这里用到的 Jsoup 选择器是a[href]意思是选中所有带 href 属性的a标签。但并不是所有链接都要保留所以我还加了一个路径特征正则只保留路径中包含anime、detail、play、bangumi的链接。以专题页为例这种页面里通常有大量无关链接——导航栏、版权声明、广告跳转路径特征正则能滤掉八成噪音。public class LinkParser { // 识别详情页或播放页的路径特征按需扩展 private static final Pattern PATH_PATTERN Pattern.compile( /(anime|detail|play|bangumi)/([a-zA-Z0-9_-])); public ListAnimeLink parse(Document doc, String baseUrl) { ListAnimeLink result new ArrayList(); // 选中页面里所有带 href 的 a 标签 for (Element a : doc.select(a[href])) { // absUrl 会自动把相对路径拼上 baseUri避免手动拼接出错 String href a.absUrl(href); if (PATH_PATTERN.matcher(href).find()) { String title a.text().trim(); if (title.length() 2) { result.add(new AnimeLink(title, href, baseUrl, LocalDateTime.now())); } } } return result; } }这段代码有两个细节值得注意。第一absUrl方法要求Document的 baseUri 正确。如果你用Jsoup.parse(html)这种方式解析字符串baseUri 是空的absUrl会原样返回相对路径后面转绝对链接就成了噩梦。正确做法是用Jsoup.connect(url).get()它会自动把请求 URL 作为 baseUri。第二title.length() 2这个判断是为了过滤掉那些只有图标没有文字的链接——很多导航站的a标签里只有一个图片text()提取出来是空字符串。3.3 过滤器剔除资源链接与导航噪音解析器拿到的是原始链接里面还混着不少不能用的东西。比如某个详情页里嵌了.mp4视频直链、.jpg封面图或者底部分页器的?page2这种东西。ResultFilter负责做二次清洗核心逻辑是流式处理public class ResultFilter { // 静态资源后缀直接排除 private static final ListString RESOURCE_SUFFIXES List.of(.jpg, .jpeg, .png, .gif, .webp, .mp4, .zip, .rar); // 常见的无效链接路径特征 private static final ListString NOISE_KEYWORDS List.of(login, register, about, privacy, javascript, mailto); public ListAnimeLink filter(ListAnimeLink raw) { return raw.stream() .filter(this::notResourceSuffix) .filter(this::notNoiseKeyword) .filter(this::deduplicate) .sorted(Comparator.comparing(AnimeLink::title)) .toList(); } private boolean deduplicate(AnimeLink link) { // 已经存在的 URL 不再加入使用 TreeSet 保证去重效率 String key link.url().replaceAll(\\?.*$, ); // 去掉 query 参数再做 key return seenKeys.add(key); } }deduplicate方法里的replaceAll(\\?.*$, )是血泪经验换来的——两个 URL 分别为https://example.org/anime/123和https://example.org/anime/123?utm_sourceindex如果不做这个处理SET 去重会认为它们是两个不同的链接最终输出一份全是重复番剧的清单。另一个有意思的点是sorted按标题字典序排序这样生成的 Markdown 表格连载观看时不用自己整理顺序。3.4 多线程抓取与超时控制单线程抓取在专题页只有一屏链接时没问题但如果目标是一个收录了三百部番剧的分类页上游响应稍慢就要等十几秒。项目的解决方式是线程池 Future 批量收集结果而且设置了线程池的拒绝策略避免请求堆积把目标站点打崩。public ListAnimeLink generate(String seedUrl) throws Exception { // 第一步先抓种子页发现所有索引页地址 ListString indexPages discoverIndexPages(seedUrl); ExecutorService pool Executors.newFixedThreadPool(threadPoolSize); try { // 每个索引页提交一个任务去解析Future 保存线程执行结果 ListFutureListAnimeLink futures new ArrayList(); for (String page : indexPages) { futures.add(pool.submit(() - parsePage(page))); } // 依次取回结果合并成一个大集合交给过滤器 ListAnimeLink merged new ArrayList(); for (FutureListAnimeLink future : futures) { merged.addAll(future.get(15, TimeUnit.SECONDS)); // 单页解析超过 15 秒视为失败 } return new ResultFilter().filter(merged); } finally { pool.shutdownNow(); // 无论成功还是异常线程池必须关闭 } }这里pool.shutdownNow()是防呆设计——Java 线程池里的线程默认是非守护线程如果你忘记关闭线程池程序中 JVM 即使执行完main也不会退出控制台看起来像卡住了。另一个细节是future.get(15, TimeUnit.SECONDS)设了单任务超时防止某个索引页因为网络问题迟迟不返回把整个生成流程拖死。4. 构建与运行mvn 打包到输出可验证的链接清单4.1 环境准备与打包运行这个项目需要 JDK 17 以上和 Maven 3.8。JDK 版本要求来自代码里用了switch箭头语法和toList()方法这些都是 Java 16 之后才有的。打包命令极其常规mvn clean package -DskipTests执行完之后target/目录下会生成anime-generator-1.0.0.jar。我习惯在pom.xml里配maven-shade-plugin把 Jsoup 依赖打进同一个 jar这样拷到服务器上单文件就能跑不用再装 classpath。4.2 运行参数与使用示例参数默认值含义--url无必填目标专题页或索引页 URL--outanime-list.md输出文件路径支持.md和.txt--threads4并发抓取线程数对慢站建议降到 2--timeout8000单次 HTTP 连接超时毫秒实际运行示例java -jar target/anime-generator-1.0.0.jar \ --url https://anime-source.example.org/season/2025 \ --out 2025-spring.md \ --threads 4屏幕上会打印日志先显示发现索引页数量再逐步显示每个索引页解析出来的链接数量最后一行输出完成解析到 37 条链接已写入 2025-spring.md。如果某个索引页抓取失败日志会打上WARN级别的那个 URL程序不会中断而是跳过继续跑——这是抓取工具必须有的容错否则一条网络抖动就毁了整次解析。4.3 输出文件说明与验证生成的 Markdown 文件长这样| 番剧名 | 链接 | 来源页 | |---|---|---| | 某某冒险谭 | https://anime-source.example.org/detail/1024 | https://anime-source.example.org/season/2025 | | 某某料理日记 | https://anime-source.example.org/detail/2048 | https://anime-source.example.org/season/2025 |拿到文件后验证链接有效性是必须走的一步。我平时用一个 bash 命令行循环逐个发 HEAD 请求检查状态码这个动作不能省因为页面可能是动态渲染的Jsoup 拿到的是静态壳子里面的链接可能已经被前端框架改写过while read url; do code$(curl -o /dev/null -s -w %{http_code} $url) echo $code $url done (grep -oP https://[^| ] 2025-spring.md | sort -u)如果状态码是 200 或 302说明链接可访问如果是 404 或 403说明这个条目已经失效或需要特殊请求头。403 不一定代表链接彻底失效很可能是目标站点开启了防盗链参考下一章的避坑记录。5. 避坑与常见问题HTML 解析和链接去重里的五个翻车现场5.1 页面乱码GBK 页面解析出一堆问号现象解析结果里所有中文标题都变成了???但英文和数字正常。原因Jsoup 从meta charset标签识别编码。但有些老站点用的是 GBK 编码且页面里没有meta charset或者 meta 声明写在 body 里Jsoup 默认按 UTF-8 解析导致乱码。解决在连接阶段显式指定编码Connection conn Jsoup.connect(url) .header(Accept-Charset, utf-8,gbk) .timeout(timeoutMs); Document doc conn.get();如果问题依旧先手动打印doc.charset()看 Jsoup 识别成了什么再用doc Jsoup.parse(conn.get().html(), url, Parser.htmlParser())强制指定编码。这一步直接影响解析器的title提取结果乱码页面生成的清单完全没有可用性。5.2 请求被 403 拦截只带了默认 UA现象用Jsoup.connect(url).get()访问目标页面返回 403但浏览器打开完全正常。原因服务端做了基础反爬检查Jsoup 默认的 User-Agent 是Java/17这种字符串一眼就是程序请求部分站点会直接拒绝。解决给连接器加上浏览器 UA 和 Referer 头我在DefaultSourceConnector里写了默认值.header(User-Agent, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36) .header(Accept, text/html,application/xhtmlxml) .header(Referer, seedPageUrl)需要多说一句遇到 403 不要盲目调大重试次数先确认是不是 UA 问题。如果是登录后才能看的页面加什么请求头都没用必须走登录态 cookie 方案这是第二个级别的问题。5.3 重复链接去不掉query 参数导致 SET 去重失效现象输出清单里同一部番剧出现三次URL 分别是/detail/1024、/detail/1024?source1、/detail/1024#comments。原因#锚点不会发送到服务端但保留在字符串里?source1是跟踪参数但服务端返回的内容完全一致。如果直接用 URL 字符串做 set key这三条会被当成三个不同链接。解决在ResultFilter.deduplicate里做归一化处理。我在前面代码里已经演示了去掉 query 参数但锚点也要去掉更严谨的做法是只保留scheme://host/path部分。如果番剧详情页链接本身包含有效参数比如/detail/1024?ep5表示第 5 集那把参数去掉会导致不同集数被合并这种情况需要根据页面实际逻辑决定 key 的粒度。5.4 线程池把站点打崩并发太快被限流封 IP现象--threads 8跑一个三百部番剧的分类页前五十条正常后面全部超时然后连首页都访问不了。原因目标站点做了速率限制8 个线程同时请求短时间触发 WAF 拉黑。解决把默认线程数降为 4并发太高收益有限但风险成倍增加。更稳妥的做法是在SourceConnector里加一个信号量控制请求间隔Semaphore rateLimiter new Semaphore(4); // 最多 4 个并发 // 每次请求前 acquire请求结束后 release线程池负责并行度信号量负责限速两者配合才能既快又不惹事。我自己跑这种索引页的经验是线程数 4、每次请求间隔 100ms 重试两次基本能稳定跑完。5.5 相对链接转绝对链接丢 baseUri现象输出的链接全是/anime/123这种相对路径双击打不开还需要手动拼接域名。原因有人写完代码偷懒用了a.attr(href)而不是a.absUrl(href)。attr返回的是 HTML 里原始值absUrl会基于 Document 的 baseUri 做拼接。没有 baseUri 的情况下absUrl返回值就是原样相对路径。解决检查Document是怎么创建的。确保用的是Jsoup.connect(url).get()而不是Jsoup.parse(html)。如果是调试阶段必须用Jsoup.parse(html)记得手动指定 baseUriDocument doc Jsoup.parse(html, https://anime-source.example.org);这个坑最容易出现在单元测试里——测试代码用本地 HTML 字符串构造 Document解析逻辑跑通一到线上真实链接就全是相对路径排查起来非常隐蔽。6. 二次开发把链接生成器改造成下载任务生成器6.1 扩展新站点源解析器里的PATH_PATTERN是按通用路径写的但现实是每个站点的 URL 结构不一样。二次开发的第一步通常是新增一个RegexSourceConnector允许通过配置文件指定路径正则和标题选择器。我在config.properties里预留了扩展位source.anime.pathPattern/anime/[0-9]$ source.anime.titleSelectora.card__title.link改造方法是在LinkParser里增加一个可注入的Pattern和Selector字段启动时从配置读取默认值保持原来的通用规则。这样新增站点时不用改 Java 代码只改配置就能适配。6.2 对接 anime-downloader输出格式扩展这个项目本身只生成链接真正下载是另一回事。把输出格式改成下载任务清单是一种顺手的事。在OutputWriter接口里新增一个M3u8OutputWriter把 AnimeLink 转成下载器可识别的任务文件public class M3u8OutputWriter implements OutputWriter { Override public void write(ListAnimeLink links) throws IOException { try (BufferedWriter writer Files.newBufferedWriter(Paths.get(tasks.txt))) { for (AnimeLink link : links) { // 兼容常见下载器标准格式链接和文件名之间用逗号分隔 writer.write(link.url() , link.title() .mp4\n); } } } }配合--out tasks.txt参数生成的清单可以直接喂给下载器或 aria2 批量下载。实际做对接时要注意动漫详情页往往不是视频直链而是嵌入了播放器页面需要再深入一层解析出真实视频地址这个逻辑建议独立写成VideoResolver避免把解析器搞成一坨。6.3 定时增量更新只输出新增番剧最后一个常用的改造是增加定时任务能力。我加了--schedule daily参数内部用ScheduledExecutorService每天固定时间跑一次抓取然后对比上次输出的标题集合只把新增条目追加到文件末尾。核心逻辑是复用ResultFilter的seenKeys——把上次的链接读进来作为初始 key新抓取的链接自然只保留新增部分。从那以后我每次改完输出格式都会强制走一遍全流程验证先用控制台输出跑种子页再生成文件检查格式最后抽样三个链接做 HTTP 状态验证。改解析器最怕的就是带着对页面结构的错误假设上线这种假设最后都会变成半夜里的告警短信。希望帮到你。本文还有配套的精品资源点击获取