URL高亮与内容解析实现方案

URL能够智能识别并高亮显示,同时解析出对应网页的标题,这样一来,用户只需轻轻一点即可跳转到目标网页,即便不点击也能一目了然地了解该网站的主要功能。这样的设计无疑为用户带来了极大的便利。那么,这样的功能是如何实现的呢?其中又涉及了哪些细节呢?本文将详尽地从调研开始,到方案的选择,再到具体的技术实现,一一为您揭晓。

调研

URL的识别其实相当直接,主要依赖于正则表达式的匹配。同样地,URL标题的解析也并不复杂,我们只需预先进行一次访问,然后从中抓取对应网站的title标签内容即可。
然而,真正的挑战在于我们如何选择解析或匹配URL的时机。这一步骤应该由后端来完成,还是由前端来负责?是应该在数据入库时就进行处理,还是在列表查询时才进行?
此外,我们还需要考虑与前端之间的消息交互机制。如何确保前后端之间的数据传递既高效又准确,是我们在设计整个系统时必须仔细考虑的问题。通过合理的设计和优化,我们可以确保URL的识别、解析以及前后端之间的消息传递都能顺畅进行,从而提升用户体验和系统性能。
调研了一下市面上已经成熟的产品:知识星球

在这里插入图片描述

星球会解析url的标题。通过抓包就可以了解他们实现的思路。

点击info下的消息体,复制出来发现被unicode编码了,进行解码一下。

https://www.jyshare.com/front-end/3602/

后端通过一种特定的格式直接将信息传递给前端,这些信息中明确指定了title的文本内容和对应的跳转链接。那么,我们是否也应该效仿这种做法由后端来负责执行呢。

方案选型

首先我们思考下url什么时候去解析。url的正则匹配不怎么耗时,主要是请求外部网站解析标题的时候比较耗时。

img
你可以选择以下三种方式:
1.在消息发送并入库之前进行解析。
2.用户访问消息列表时,后端进行实时解析。
3.将原文直接呈现给用户,由前端自行解析。
首先,我们排除第二种方式,因为对于同一条消息,不同的用户请求都会触发重复的解析过程,这不仅增加了接口的响应时间,还占用了宝贵的后端资源。
至于第三种方式,虽然它不会占用服务端的资源,而是由用户端自行解析,但这样做会导致消息阅读者的加载速度变慢。更为重要的是,如果在一个有千人在线的群聊中发送一条链接,这一千个前端都会去请求并解析该链接,这无疑会给目标网站带来沉重的负担。因此,我们应避免给其他网站造成不必要的困扰。
经过深思熟虑,我们最终选择了第一种方式,即让消息的发送者在发送前稍微承担一些工作(既然要发链接,就稍微委屈一下吧)。
那么,是否有可能采用异步的方式呢?也就是说,在消息入库后,再进行异步解析,并将解析结果推送给其他用户。然而,考虑到消息发送者通常需要实时查看自己的链接是否已被解析,因此这种异步方式并不适用。
综上所述,我们决定让消息发送者承担这一任务。不过,请放心,我们会尽力进行必要的优化,以减轻发送者的负担。

技术实现

在技术实现之前,我们需要先进行一个最小粒度的验证。就是先验证我们能够识别url和标题解析,再去进行代码更优雅的编写。

尝试识别url

public static void main(String[] args) {
    String content= "访问我的博客 https://www.example.com/blog,里面有许多有趣的内容。如果你对编程感兴趣,可以访问这个论坛 http://forum.coding.com/index.php,它提供了很多学习资料。这是一个图片分享网站 http://photos.site.net/gallery?id=123,上面有很多漂亮的图片。ftp://files.portfolio.org/artworks/,可以看到我的最新创作。这是一个带有查询参数的URL https://search.example.net/?q=java+programming,你可以用它来搜索相关内容。在这个新闻网站 https://news.dailyupdate.com/top-stories,你可以了解最新的时事动态。";  

    Pattern pattern = Pattern.compile("((http|https)://)?(www.)?([\\w_-]+(?:(?:\\.[\\w_-]+)+))([\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])?");
    List<String> matchList = ReUtil.findAll(pattern, content, 0);//hutool工具类
    System.out.println(matchList);
}

输出的结果

[https://www.example.com/blog, http://forum.coding.com/index.php, http://photos.site.net/gallery?id=123, files.portfolio.org/artworks/, https://search.example.net/?q=java+programming, https://news.dailyupdate.com/top-stories]

大部分的case都匹配上了,没有问题。其实想要找一个完美的链接匹配正则是有难度的。这个正则还是用gpt4生成的。

尝试获取标题

引入jsoup依赖

<dependency>
    <groupId>org.jsoup</groupId>
    <artifactId>jsoup</artifactId>
    <version>1.15.3</version>
</dependency>

获取标题,我们可用java常用的爬虫工具Jsoup来进行内容的解析。一般浏览器展示的标签页的文本,就是html里面的<title></title>标签

public static void main(String[] args) throws IOException {
    Connection connect = Jsoup.connect("http://www.baidu.com");
    Document document = connect.get();
    String title = document.title();
    System.out.println(title);
}

输出结果

百度一下,你就知道

为了让你能更加清楚的了解jsoup爬取到的document是啥样,我们debug看看

img

其实就是一个html页面。我们通过document.title()去匹配标题的标签内容。

所有网页的标题都是这个title标签吗?事实上,不同的页面,标题在不同的标签内,需要不同的解析方式。我们也不知道哪些标题需要用到什么解析方式,只能把解析不出来的标题打个日志,然后再去慢慢的添加更多的解析方式,那未来肯定会扩展很多的解析方式。

把标题的解析方式做成解析器。每个解析器串成一个链条。通用的解析器优先级更高。链条中直到其中某个解析器解析出标题,就返回。

解析器串起的链条就是责任链模式,创建责任链的地方就是工厂模式。而不同的类实现不同的url解析方法,这就是策略模式,而抽象类里面的逻辑,又像是模板方法模式,一口气就能实现四种模式。

搭建url解析框架

正好想到之前学spring源码的时候,看见spring的参数解析器(就是识别方法的参数名)和我们的这个需求类似,我们可以参考spring去进行框架的搭建。

定义接口,核心的接口是getUrlContentMap,其他的方法只是细分的获取步骤。

public interface UrlDiscover {


    @Nullable
    Map<String,UrlInfo> getUrlContentMap(String content);

    @Nullable
    UrlInfo getContent(String url);

    @Nullable
    String getTitle(Document document);

    @Nullable
    String getDescription(Document document);

    @Nullable
    String getImage(String url, Document document);
}

公共的逻辑放在抽象类。子类commonUrlWxUrl就是不同的标题解析策略。

在这里插入图片描述
当一个用户发送了一段包含众多URL的消息时,我们是否需要按顺序逐个解析这些URL呢?实际上,由于每个URL的解析过程相互独立,没有必要串行处理。如果采用并行方式解析,将显著减少总体耗时,从而极大地提升用户体验,尤其是对于消息发送者而言,长时间等待是不可接受的。同时,考虑到如GitHub这类网站可能存在的较长请求响应时间,我们应设置合理的超时机制,例如超过1秒仍未获取到内容则放弃请求,以防止单个URL影响整体响应速度。

因此,为了提高效率和优化用户体验,我们可以利用多线程或异步IO技术来并发处理所有URL的解析任务。这样一来,每个URL解析会被分配到单独的线程或进程中执行,不再需要等待前一个URL解析完毕。最终,整个解析流程的耗时将主要取决于最慢的那个URL的解析所需时间,而非所有URL解析时间的叠加。
这样的一个并行框架要怎么去写呢?可以用CompletableFuture,美团的一篇技术文章:CompletableFuture原理与实践-外卖商家端API的异步化

美团提供了一个异步的工具类,对CompletableFuture进行了一层封装,用起来更加简洁优雅。

public abstract class AbstractUrlDiscover implements UrlDiscover {
    //链接识别的正则
    private static final Pattern PATTERN = Pattern.compile("((http|https)://)?(www.)?([\\w_-]+(?:(?:\\.[\\w_-]+)+))([\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])?");


    @Nullable
    @Override
    public Map<String, UrlInfo> getUrlContentMap(String content) {

        if (StrUtil.isBlank(content)) {
            return new HashMap<>();
        }
        List<String> matchList = ReUtil.findAll(PATTERN, content, 0);

        //并行请求
        List<CompletableFuture<Pair<String, UrlInfo>>> futures = matchList.stream().map(match -> CompletableFuture.supplyAsync(() -> {
            UrlInfo urlInfo = getContent(match);
            return Objects.isNull(urlInfo) ? null : Pair.of(match, urlInfo);
        })).collect(Collectors.toList());
        CompletableFuture<List<Pair<String, UrlInfo>>> future = FutureUtils.sequenceNonNull(futures);
        //结果组装
        return future.join().stream().collect(Collectors.toMap(Pair::getFirst, Pair::getSecond, (a, b) -> a));
    }

    @Nullable
    @Override
    public UrlInfo getContent(String url) {
        Document document = getUrlDocument(assemble(url));
        if (Objects.isNull(document)) {
            return null;
        }

        return UrlInfo.builder()
                .title(getTitle(document))
                .description(getDescription(document))
                .image(getImage(assemble(url), document)).build();
    }


    private String assemble(String url) {

        if (!StrUtil.startWith(url, "http")) {
            return "http://" + url;
        }

        return url;
    }

    protected Document getUrlDocument(String matchUrl) {
        try {
            Connection connect = Jsoup.connect(matchUrl);
            connect.timeout(2000);
            return connect.get();
        } catch (Exception e) {
            log.error("find error:url:{}", matchUrl, e);
        }
        return null;
    }

    /**
     * 判断链接是否有效
     * 输入链接
     * 返回true或者false
     */
    public static boolean isConnect(String href) {
        //请求地址
        URL url;
        //请求状态码
        int state;
        //下载链接类型
        String fileType;
        try {
            url = new URL(href);
            HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection();
            state = httpURLConnection.getResponseCode();
            fileType = httpURLConnection.getHeaderField("Content-Disposition");
            //如果成功200,缓存304,移动302都算有效链接,并且不是下载链接
            if ((state == 200 || state == 302 || state == 304) && fileType == null) {
                return true;
            }
            httpURLConnection.disconnect();
        } catch (Exception e) {
            return false;
        }
        return false;
    }

}

public class CommonUrlDiscover extends AbstractUrlDiscover {
    @Nullable
    @Override
    public String getTitle(Document document) {
        return document.title();
    }

    @Nullable
    @Override
    public String getDescription(Document document) {
        String description = document.head().select("meta[name=description]").attr("content");
        String keywords = document.head().select("meta[name=keywords]").attr("content");
        String content = StrUtil.isNotBlank(description) ? description : keywords;
        //只保留一句话的描述
        return StrUtil.isNotBlank(content) ? content.substring(0, content.indexOf("。")) : content;
    }

    @Nullable
    @Override
    public String getImage(String url, Document document) {
        String image = document.select("link[type=image/x-icon]").attr("href");
        //如果没有去匹配含有icon属性的logo
        String href = StrUtil.isEmpty(image) ? document.select("link[rel$=icon]").attr("href") : image;
        //如果url已经包含了logo
        if (StrUtil.containsAny(url, "favicon")) {
            return url;
        }
        //如果icon可以直接访问或者包含了http
        if (isConnect(!StrUtil.startWith(href, "http") ? "http:" + href : href)) {
            return href;
        }

        return StrUtil.format("{}/{}", url, StrUtil.removePrefix(href, "/"));
    }


}
public class WxUrlDiscover extends AbstractUrlDiscover {

    @Nullable
    @Override
    public String getTitle(Document document) {
        return document.getElementsByAttributeValue("property", "og:title").attr("content");
    }

    @Nullable
    @Override
    public String getDescription(Document document) {
        return document.getElementsByAttributeValue("property", "og:description").attr("content");
    }

    @Nullable
    @Override
    public String getImage(String url, Document document) {
        String href = document.getElementsByAttributeValue("property", "og:image").attr("content");
        return isConnect(href) ? href: null;
    }
}

prioritizedUrl是我们的策略类,同时也是组装责任链的工厂。如果你调用它,它会按顺序执行责任链,直到解析出url标题。

public class PrioritizedUrlDiscover extends AbstractUrlDiscover {

    private final List<UrlDiscover> urlDiscovers = new ArrayList<>(2);

    //组装责任链
    public PrioritizedUrlDiscover() {
        urlDiscovers.add(new WxUrlDiscover());
        urlDiscovers.add(new CommonUrlDiscover());
    }


    @Nullable
    @Override
    public String getTitle(Document document) {
       //顺序执行责任链
        for (UrlDiscover urlDiscover : urlDiscovers) {
            String urlTitle = urlDiscover.getTitle(document);
            if (StrUtil.isNotBlank(urlTitle)) {
                return urlTitle;
            }
        }
        return null;
    }

    @Nullable
    @Override
    public String getDescription(Document document) {
        for (UrlDiscover urlDiscover : urlDiscovers) {
            String urlDescription = urlDiscover.getDescription(document);
            if (StrUtil.isNotBlank(urlDescription)) {
                return urlDescription;
            }
        }
        return null;
    }

    @Nullable
    @Override
    public String getImage(String url, Document document) {
        for (UrlDiscover urlDiscover : urlDiscovers) {
            String urlImage = urlDiscover.getImage(url,document);
            if (StrUtil.isNotBlank(urlImage)) {
                return urlImage;
            }
        }
        return null;
    }
}

看源码能让我们更加深入的理解设计模式的运用,更能为我们平时的开发提供思路灵感

这样一个url解析框架基本完成,发送消息的时候只需要匹配出所有url,然后通过责任链去解析标题就ok了。

  • 23
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值