1. 项目概述从富文本中“挖矿”做过后台管理系统的朋友对富文本编辑器肯定不陌生。无论是发布新闻、编辑商品详情还是运营活动页面我们都会用到像 WangEditor、UEditor、TinyMCE 或者 CKEditor 这类工具。用户上传的图片、视频、附件在编辑器里看起来一切正常但当你点击“保存”按钮将那一大段 HTML 代码存入数据库后一个潜在的问题就埋下了这些富文本内容里嵌入了大量的资源地址。这些地址可能是用户本地上传后临时缓存在服务器某个目录下的图片也可能是直接复制粘贴过来的第三方图床链接。对于后端开发者尤其是使用 Java 技术栈的我们来说如何从这一团“HTML 乱麻”中精准、高效地提取出所有的资源地址并进行后续处理比如下载到本地、转存到对象存储、替换域名等就成了一个绕不开的“脏活累活”。这个项目要解决的就是如何用 Java 这把“手术刀”干净利落地完成这项“挖矿”任务——从富文本 HTML 中提取出所有有价值的资源 URL。这不仅仅是简单的字符串匹配。一个成熟的方案需要考虑正则表达式的准确性、HTML 结构的复杂性、性能开销以及后续业务逻辑的衔接。比如用户可能上传了十张图片你不仅要把这十张图片的地址找出来可能还需要将它们从临时目录迁移到正式存储位置并更新 HTML 中的链接。如果提取不全或出错前端页面就会显示一堆“裂图”直接影响用户体验和数据完整性。2. 核心需求与方案设计解析2.1 需求拆解我们到底要“抓”什么在动手写代码之前我们必须明确目标。从富文本中提取资源地址核心是识别 HTML 标签及其src或href属性。主要目标包括图片资源最常见也是最核心的。对应img标签的src属性。例如img srchttps://cdn.example.com/upload/2023/11/abc.jpg alt示例。视频/音频资源对应video的src属性或source标签。例如video controlssource src/uploads/video.mp4 typevideo/mp4/video。文件附件对应a标签的href属性且通常链接指向一个文件。例如a href/downloads/doc.pdf产品手册/a。背景图片内联样式中的background-image属性。例如div stylebackground-image: url(bg.png);。更深层次的需求还包括去重同一资源可能在内容中出现多次提取列表时应去重。过滤需要区分“本地临时地址”和“外部永久地址”。例如我们可能只关心src为/temp/upload/开头的图片而对于src是https://third-party-cdn.com/的图片则忽略或仅做记录。上下文信息有时我们不仅需要 URL还需要知道这个 URL 来自哪个标签、在文中的大致位置以便于更精细化的处理或回滚。性能当处理海量历史数据或极长的富文本内容时提取算法的效率至关重要。2.2 技术方案选型正则 vs 解析器面对一段 HTML 字符串主流有两种提取思路方案一正则表达式 (Regex)这是最直观、最轻量的方法。直接编写模式去匹配src...或href...这样的字符串。优点无需引入额外依赖简单快速对于格式规整、可控的 HTML 片段如完全由特定编辑器生成非常有效。缺点脆弱。HTML 不是正则语言复杂情况极易出错。例如属性值可能用单引号src...或没有引号src...。标签可能跨行。属性值内部可能包含字符虽然不常见但在 URL 的查询参数中是可能的。无法处理注释掉的标签或脚本中的字符串。注意很多初学者会掉进“用正则解析 HTML”的陷阱。对于来源不可控、结构复杂的 HTML正则表达式维护成本高且容易产生隐蔽的 Bug。方案二HTML 解析器 (Parser)使用专门的 HTML 解析库如Jsoup。它将 HTML 字符串解析成一个 DOM文档对象模型树允许你像在前端使用 JavaScript 一样使用选择器来遍历和查询元素。优点健壮、准确。能正确处理各种格式的 HTML自动处理标签嵌套、属性引号等问题。API 强大且易用。缺点需要引入第三方库会带来额外的体积和解析开销但对于现代服务器性能而言这点开销在绝大多数场景下可忽略不计。结论与选型建议 对于企业级、需要稳定可靠运行的 Java 后端项目强烈推荐使用 Jsoup。它几乎是 Java 生态中处理 HTML 的“标准答案”。正则表达式仅适用于非常简单的、你百分之百能确定其格式的场景或者作为在解析器基础上的一个快速过滤补充。本项目将主要基于 Jsoup 来展开因为它能让我们更专注于业务逻辑而非字符串处理的细枝末节。3. 核心实现基于 Jsoup 的健壮提取方案3.1 环境准备与基础依赖首先在你的 Maven 项目的pom.xml中添加 Jsoup 依赖。建议使用最新稳定版。dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.17.2/version !-- 请检查并使用最新版本 -- /dependency如果你是 Gradle 项目则在build.gradle的dependencies块中添加implementation org.jsoup:jsoup:1.17.23.2 基础提取方法实现我们来构建一个核心的工具类RichTextResourceExtractor。先从最简单的功能开始提取所有图片地址。import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; public class RichTextResourceExtractor { /** * 从富文本HTML中提取所有图片的URL地址。 * param html 富文本HTML字符串 * return 图片URL列表已去重 */ public static ListString extractImageUrls(String html) { ListString imageUrls new ArrayList(); if (html null || html.trim().isEmpty()) { return imageUrls; } // 1. 使用Jsoup解析HTML。这里不指定baseUri因为我们只关心属性值。 Document doc Jsoup.parse(html); // 2. 选择所有img标签 Elements imgElements doc.select(img); // 3. 遍历并获取src属性 for (Element img : imgElements) { String src img.attr(src); // attr方法能正确处理空属性、相对/绝对路径 if (src ! null !src.trim().isEmpty()) { imageUrls.add(src.trim()); } } // 4. 去重并返回 return imageUrls.stream().distinct().collect(Collectors.toList()); } }代码解读与实操要点Jsoup.parse(html)这是解析的入口。它将字符串转换为一个Document对象。对于片段HTML没有htmlbody包裹Jsoup也能很好处理。doc.select(img)使用 CSS 选择器进行查询。img选择所有图片标签。Jsoup 的选择器非常强大你还可以用img[src]选择有src属性的图片或者img[src^/uploads/]选择src以/uploads/开头的图片。img.attr(src)获取属性的值。这个方法比直接拼接字符串安全得多它会返回空字符串如果属性不存在而不是null除非元素为null。我们通过trim()去除首尾空格。去重使用 Java 8 Stream 的distinct()方法这是一个简单有效的去重方式。注意它依赖于String的equals方法所以“/a.jpg”和“/a.jpg?t123”会被视为不同的 URL。如果业务需要更智能的去重如忽略查询参数需要自己实现。3.3 进阶提取支持多种资源与过滤一个完整的提取器应该能处理多种资源类型并允许调用者进行过滤。// 在 RichTextResourceExtractor 类中继续添加方法 /** * 提取指定类型的资源URL。 * param html 富文本HTML字符串 * param resourceType 资源类型枚举 * param filterPredicate 自定义过滤器可为null * return 资源URL列表已去重 */ public static ListString extractUrls(String html, ResourceType resourceType, java.util.function.PredicateString filterPredicate) { ListString urls new ArrayList(); if (html null || html.trim().isEmpty()) { return urls; } Document doc Jsoup.parse(html); String cssSelector; String attrName; // 根据资源类型确定选择器和属性名 switch (resourceType) { case IMAGE: cssSelector img; attrName src; break; case VIDEO: // 匹配video标签的src以及video内的source标签的src cssSelector video, video source; attrName src; break; case AUDIO: cssSelector audio, audio source; attrName src; break; case ATTACHMENT: // 通常附件是带有href的a标签这里简单匹配所有a标签实际可根据href后缀或class进一步过滤 cssSelector a[href]; attrName href; break; case BACKGROUND_IMAGE: // 背景图需要从style属性中提取处理更复杂这里先返回空 return extractBackgroundImageUrls(html); default: return urls; } Elements elements doc.select(cssSelector); for (Element el : elements) { String url el.attr(attrName); if (url ! null !url.trim().isEmpty()) { url url.trim(); // 应用自定义过滤器 if (filterPredicate null || filterPredicate.test(url)) { urls.add(url); } } } return urls.stream().distinct().collect(Collectors.toList()); } /** * 提取内联样式中的背景图片URL简易版。 * 注意此方法使用正则仅适用于简单的 url(...) 格式。 */ private static ListString extractBackgroundImageUrls(String html) { ListString urls new ArrayList(); // 这是一个简化的正则匹配 style* 中的 background-image: url(...); // 实际项目建议使用更严谨的CSS解析库或结合Jsoup获取style属性后再用正则解析。 java.util.regex.Pattern pattern java.util.regex.Pattern.compile( background-image\\s*:\\s*url\\([\]?([^\)])[\]?\\), java.util.regex.Pattern.CASE_INSENSITIVE ); java.util.regex.Matcher matcher pattern.matcher(html); while (matcher.find()) { String url matcher.group(1).trim(); if (!url.isEmpty()) { urls.add(url); } } return urls.stream().distinct().collect(Collectors.toList()); } // 资源类型枚举 public enum ResourceType { IMAGE, VIDEO, AUDIO, ATTACHMENT, BACKGROUND_IMAGE }代码解读与实操要点枚举与策略使用ResourceType枚举来明确资源类型使方法调用更清晰避免魔法字符串。CSS 选择器进阶video, video source这个选择器意味着选择所有video标签以及所有作为video标签直接子元素的source标签。这能覆盖大多数视频嵌入情况。自定义过滤器参数PredicateString filterPredicate是一个函数式接口允许调用者传入自定义的过滤逻辑。例如只提取本地临时文件url - url.startsWith(/temp/upload)。背景图片提取的复杂性提取内联样式中的背景图是难点。上述方法提供了一个基于正则的简易方案但它不完美。对于生产环境如果背景图功能很重要可以考虑先用 Jsoup 提取所有具有style属性的元素。使用一个简单的 CSS 规则解析器如github.com/raphaelcss/css-parser来解析style字符串从中获取background-image属性值。从url(...)中提取出真正的 URL。实操心得在大多数 UGC用户生成内容场景中用户通过富文本编辑器直接设置背景图的情况较少。如果这个需求不是特别强烈可以和产品经理沟通是否可以先不支持或者引导用户使用img标签替代以简化技术实现。3.4 提取并携带上下文信息有时仅仅拿到 URL 列表还不够。我们可能需要在后续步骤中替换这些 URL或者记录资源在原文中的位置。这就需要我们提取更丰富的信息。import java.util.Objects; // 资源项包含URL及其在原文中的上下文信息 public class ResourceItem { private final String url; private final ResourceType type; private final String tagName; // 原始标签名如 img, a private final String outerHtml; // 标签的完整HTML字符串便于直接替换 // 可以添加更多字段如元素在文档中的索引等 // 构造器、Getter、equals、hashCode 省略... // 注意基于 outerHtml 实现 equals 和 hashCode用于去重 } // 在 RichTextResourceExtractor 类中添加方法 /** * 提取资源项列表包含上下文信息。 * param html 富文本HTML * param resourceType 资源类型 * param filterPredicate 过滤器 * return 资源项列表根据outerHtml去重 */ public static ListResourceItem extractResourceItems(String html, ResourceType resourceType, java.util.function.PredicateResourceItem filterPredicate) { ListResourceItem items new ArrayList(); if (html null || html.trim().isEmpty()) { return items; } Document doc Jsoup.parse(html); String cssSelector; String attrName; // ... (与 extractUrls 方法相同的 switch 逻辑确定选择器和属性名) // 为简洁此处省略重复代码。实际应将这部分逻辑抽取为私有方法。 Elements elements doc.select(cssSelector); for (Element el : elements) { String url el.attr(attrName); if (url ! null !url.trim().isEmpty()) { url url.trim(); ResourceItem item new ResourceItem(url, resourceType, el.tagName(), el.outerHtml()); if (filterPredicate null || filterPredicate.test(item)) { items.add(item); } } } // 基于 ResourceItem 的 equals/hashCode (通常基于 outerHtml) 去重 return items.stream().distinct().collect(Collectors.toList()); }为什么需要outerHtml假设我们提取到一个图片标签img src/temp/a.jpg classpreview后续我们下载了这张图片到云存储得到了新地址https://oss.com/prod/a.jpg。如果我们只有 URL/temp/a.jpg要替换原文中的这个图片就变得困难因为原文中可能有多个图片的src都是/temp/a.jpg或者 URL 是相对路径直接进行字符串替换容易出错。而有了outerHtml我们可以进行更精确的替换html html.replace(item.getOuterHtml(), newOuterHtml)。当然更优雅的做法是使用 Jsoup 直接修改元素的attr然后输出整个Document的 HTML。4. 性能优化与生产级考量当需要处理成千上万篇历史文章或者单篇文章体积巨大比如包含几十张高清图片的 HTML时性能就需要被纳入考量。4.1 避免重复解析如果一篇文章需要被多次提取不同类型的资源最糟糕的做法是每次调用extractImageUrls、extractVideoUrls都重新Jsoup.parse(html)一次。解析 HTML 构建 DOM 树是有成本的。优化方案设计一个入口方法一次性解析然后分发查询。public class RichTextResourceExtractor { /** * 资源提取结果集 */ public static class ExtractionResult { private ListString imageUrls; private ListString videoUrls; private ListString attachmentUrls; // ... 其他类型 private Document parsedDoc; // 缓存解析后的Document // Getter and Setter ... } /** * 一次性提取所有类型的资源推荐用于批量处理 */ public static ExtractionResult extractAll(String html) { ExtractionResult result new ExtractionResult(); if (html null || html.trim().isEmpty()) { return result; } Document doc Jsoup.parse(html); result.setParsedDoc(doc); // 缓存 result.setImageUrls( doc.select(img[src]).eachAttr(src).stream() .map(String::trim).filter(s - !s.isEmpty()).distinct().collect(Collectors.toList()) ); // eachAttr() 是Jsoup提供的便捷方法直接获取属性列表 result.setVideoUrls( doc.select(video[src], video source[src]).eachAttr(src).stream() .map(String::trim).filter(s - !s.isEmpty()).distinct().collect(Collectors.toList()) ); // 附件提取可以更智能例如只提取链接后缀为常见文件格式的 result.setAttachmentUrls( doc.select(a[href]).stream() .map(el - el.attr(href).trim()) .filter(href - !href.isEmpty()) .filter(href - isFileLink(href)) // 自定义文件链接判断 .distinct() .collect(Collectors.toList()) ); return result; } private static boolean isFileLink(String href) { String lowerHref href.toLowerCase(); return lowerHref.matches(.*\\.(pdf|docx?|xlsx?|zip|rar|7z|txt)(\\?.*)?$); // 简单通过后缀判断可根据业务扩展 } }4.2 处理超大 HTML 与内存考量Jsoup 将整个 HTML 加载到内存中构建 DOM 树。对于极端大的 HTML比如几十 MB可能会消耗大量堆内存甚至引发OutOfMemoryError。应对策略流式解析Jsoup 本身不完全支持 SAX 式的流解析但你可以考虑使用其他更底层的库如HTMLParser或Jericho HTML Parser它们可以提供事件驱动的解析方式在读取到特定标签如img时触发回调边读边处理内存占用恒定。分块处理如果 HTML 结构有规律比如由多个独立的div块组成可以尝试用字符串方法粗略分割后再分别交给 Jsoup 处理。但这方法风险高容易破坏标签完整性。增大 JVM 堆内存对于大多数 Web 应用单篇富文本文章的大小是可控的通常小于 1MB。如果确实遇到问题首先应检查业务是否合理用户是否上传了过多/过大的图片其次可以考虑适当增加应用服务器的堆内存-Xmx参数。实操心得在我经历的项目中99% 的富文本内容提取场景Jsoup 的内存和性能表现都是绰绰有余的。真正的性能瓶颈往往出现在提取到 URL 之后的操作比如同步调用网络 IO 去下载这些资源。一定要将“提取”和“处理”如下载、转存解耦。提取应该是快速的内存操作提取出的 URL 列表可以放入消息队列由后台任务异步处理。4.3 异步处理与任务解耦这是生产系统中至关重要的一环。不要在保存文章的主请求线程里同步执行图片下载、转存到 OSS 等耗时操作。推荐架构主线程HTTP 请求接收富文本 HTML。调用RichTextResourceExtractor.extractAll(html)快速提取出所有本地临时资源 URL。将文章内容其中仍包含临时 URL和提取出的资源 URL 列表作为一条消息发送到消息队列如 RabbitMQ、RocketMQ、Kafka。立即返回“保存成功”响应给前端。消费者线程后台任务从消息队列消费任务。遍历资源 URL 列表逐个下载临时文件上传到正式的对象存储如阿里云 OSS、腾讯云 COS。每成功处理一个资源就用新的永久 URL 替换掉文章 HTML 中的旧临时 URL。所有资源处理完成后将最终的文章 HTML包含永久链接更新回数据库。这样用户发布体验流畅系统吞吐量高且通过消息队列保证了处理任务的可靠性失败重试。5. 常见问题、踩坑记录与排查技巧5.1 相对路径与绝对路径问题这是最常遇到的坑之一。用户上传图片后编辑器生成的可能是相对路径src/uploads/temp/abc.jpg或没有协议域的绝对路径src//cdn.example.com/img.jpg协议相对 URL。问题当你提取出这个 URL 后直接用它去下载文件可能会因为找不到主机或路径不对而失败。解决方案在提取时或提取后处理前需要将 URL规范化。使用 Jsoup 的Document解析时可以传入一个baseUri参数Jsoup.parse(html, “https://your-domain.com”)。之后当你使用element.attr(“abs:src”)注意前缀abs:时Jsoup 会自动将相对路径转换为基于baseUri的绝对路径。手动处理编写一个resolveUrl(baseUrl, relativeUrl)工具方法或者使用 Apache HttpClient 的URIUtils.resolve。5.2 处理编辑器特定的占位符或 Data URL一些编辑器在上传过程中可能会插入临时的占位符如src[uploading...]或直接将图片以 Base64 格式嵌入Data URL形如srcdata:image/png;base64,iVBORw0KGgo...。问题这些都不是有效的可访问 URL如果不对其过滤会导致后续下载逻辑出错。解决方案在提取后增加过滤逻辑。PredicateString validUrlFilter url - { if (url null || url.isEmpty()) return false; if (url.startsWith(data:)) return false; // 过滤Data URL if (url.contains([uploading]) || url.contains(blob:)) return false; // 过滤占位符和Blob URL // 可以添加更多规则如必须符合某种URL格式 return true; }; // 在 extractUrls 方法中应用此过滤器5.3 编码与特殊字符HTML 中的属性值可能包含 HTML 实体编码如srcimageamp;.jpg实际上代表srcimage.jpg。也可能包含 URL 编码如srcimage%20name.jpg空格。问题提取出的字符串是编码后的直接用于 HTTP 请求可能失败。解决方案Jsoup 的attr()方法在返回属性值时已经自动解码了 HTML 实体。所以对于amp;你会得到。但对于 URL 编码%20它不会解码。通常HTTP 客户端库如OkHttp、Apache HttpClient能够处理 URL 编码的路径。最稳妥的方式是在将 URL 用于网络请求前使用java.net.URLDecoder.decode(url, “UTF-8”)进行解码注意先判断是否需要解码避免双重解码。5.4 正则表达式方案的局限性再强调尽管我们在背景图片提取中用了正则但必须再次警告不要用正则表达式去解析复杂的、结构未知的 HTML 来获取src/href。下面是一个会出错的例子img srchttps://example.com/image.jpg altA B一个天真的正则src([^])会匹配到https://example.com/image.jpg altA 因为它在第一个处就停止了而不是在引号处。虽然可以写出更复杂的正则来规避但维护成本极高且总有新的边缘情况出现。Jsoup 这类解析器正是为了解决这些问题而生的。5.5 日志与监控在生产环境中一定要给资源提取逻辑加上详细的日志。记录提取统计每处理一篇文章记录提取出的各类资源数量。记录异常 URL对于无法处理或过滤掉的 URL如 Data URL、格式错误的 URL记录到 WARN 或 DEBUG 日志中便于后期分析和优化过滤规则。监控耗时在方法的开始和结束处记录时间监控提取过程的性能。如果发现平均耗时异常增长可能是遇到了结构异常复杂的内容需要 review。import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class RichTextResourceExtractor { private static final Logger log LoggerFactory.getLogger(RichTextResourceExtractor.class); public static ExtractionResult extractAll(String html) { long startTime System.currentTimeMillis(); if (html null) { log.warn(Input html is null.); return new ExtractionResult(); } log.debug(Starting resource extraction for html of length: {}, html.length()); // ... 提取逻辑 long endTime System.currentTimeMillis(); log.debug(Extraction completed. Found {} images, {} videos. Time taken: {} ms, result.getImageUrls().size(), result.getVideoUrls().size(), (endTime - startTime)); return result; } }处理富文本内容就像是在处理用户直接交给你的一小段“前端代码”充满了不确定性和边界情况。基于 Jsoup 构建一个健壮的提取器是地基而理解各种陷阱、设计异步架构、做好监控则是让这个功能在生产环境中平稳运行的关键。这套方案在多个中大型内容管理项目中得到了验证希望这些具体的代码和踩坑经验能帮助你更从容地应对“Java 获取富文本内容资源地址”这个看似简单实则暗藏玄机的需求。