Jsoup 在 Android 中的尝试

Jsoup 是一款 Java 的 HTML 解析工具,主要是对 HTML 和 XML 文件进行解析。所以,对 JS 动态生成内容的支持并不好。

如果想解析 HTML,因为不同网站的情况不同,一些简单的网站可以通过下面的方法尝试(复杂的我也还不会)。

具体解析要依据网站的结构,如果对前端有些了解大概能更好理解。

HTML 解析

首先添加依赖:

1
implementation 'org.jsoup:jsoup:1.14.1'

第一种方式

通过 Jsoup.connect 的方式来解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    private fun parseHtml(url: String) {
Thread {
val document = Jsoup.connect(url).get()
Log.d(TAG, document.title())

// 先看网页结构。比如:先获取 id 为 player1 的元素,然后获取其中所有的 li 元素,并打印出每个 li 元素内的 a 标签属性信息。
val elements = document.getElementById("player1")
val li = elements?.select("li")
if (li != null) {
for (e in li) {
val aElement = e.select("a")
val movieName: String = aElement.text()
val url: String = aElement.attr("href")
// Log.d("TAG_电影名称","movieName: $movieName")
// Log.d("TAG_电影详情页","url: $url")
}
}
}.start()
}

有时还可能需要提交参数(比如搜索),可以尝试以下方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
    private fun parseHtml(url: String, searchData: String) {
Log.d(TAG, "parseHtml: " + url + searchData)

Thread {
val document = Jsoup.connect(url)
.postDataCharset("GBK") // 提交的参数有汉字时,避免乱码问题。
.data("searchkey", searchData)
.post()

// 获取的内容有乱码时,也要注意编码格式。
// val document = Jsoup.parse(URL(url).openStream(), "GBK", url);

// 具体情况具体分析,看网站的结构来解析。写法不固定。
val link = document.select("link").first()
if (link!=null) {
href = link.attr("href")
Log.d(TAG, "href: " + href)
}

val elements = document.getElementById("list")
val li = elements?.select("dd")
if (li != null) {
for (e in li) {
val aElement = e.select("a")
val movieName: String = aElement.text()
val url: String = aElement.attr("href")
// Log.d("TAG_电影名称", "movieName: $movieName")
// Log.d("TAG_电影详情页url", "url: $url")
}
}
}.start()
}

第二种方式

通过 WebView 的 addJavascriptInterface 结合 Jsoup.parse 也是可以解析 HTML 的。不过,速度来讲可能会比 Jsoup.connect 的方式慢一些。(看注释的地方)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
private lateinit var mWebView: WebView

private fun parseWebView() {
mWebView = WebView(this)
mWebView.settings.javaScriptEnabled = true
// mWebView.addJavascriptInterface(InJavaScriptLocalObj(), "local_obj")
mWebView.webChromeClient = WebChromeClient()
mWebView.webViewClient = object : WebViewClient() {

// override fun onPageFinished(view: WebView, url: String) {
// super.onPageFinished(view, url)
// view.loadUrl("javascript:window.local_obj.showSource('<head>'+document.getElementsByTagName('html')[0].innerHTML+'</head>','test');")
// }
}
mWebView.loadUrl(url)
}

private inner class InJavaScriptLocalObj() {
@JavascriptInterface
fun showSource(html: String, test: String) {
// 将给定的html代码解析成文档
val document = Jsoup.parse(html)
// 具体解析要看网页的格式
// val elements = document.getElementById("player1")
// val li = elements?.select("li")
//
// if (li != null) {
// for (e in li) {
// val aElement = e.select("a")
// //电影名称
// val movieName: String = aElement.text()
// //电影详情页url
// val url: String = aElement.attr("href")
//// Log.d("TAG","movieName: $movieName")
//// Log.d("TAG","url: $url")
// }
// }

// Log.d("TAG_a[href]","===============================================")
// val links = document.select("a[href]")
// for (link in links) {
// Log.d("TAG_parse", "link : " + link.attr("href"))
// Log.d("TAG_parse", "text : " + link.text())
// }
// Log.d("TAG_a[href]","===============================================")
}
}

示例

不同类型的网站,都有它的特点。

比如小说类:主要以文字内容为主。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    private fun parseHtml(url: String) {
Log.d(TAG,"从章节点击:"+url)
Thread {
val document = Jsoup.connect(url).get()
val elements = document.getElementById("content")
Log.d(TAG, elements.toString())
runOnUiThread {
tv_novel_content.text = elements?.html()
.toString()
.replace("<br>", "")
.replace("&nbsp;", " ")
}

//设置ScrollView滚动到顶部
novel_sl.fullScroll(ScrollView.FOCUS_UP);
// //设置ScrollView滚动到顶部
// novel_sl.fullScroll(ScrollView.FOCUS_DOWN);
}.start()
}

比如漫画类:

有的时候,显示全部章节列表需要点击一下按钮。我们可以内置 js 脚本的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private fun parseWebView() {
mWebView = WebView(this)
mWebView.settings.javaScriptEnabled = true
mWebView.addJavascriptInterface(InJavaScriptLocalObj(), "local_obj")
mWebView.webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView, newProgress: Int) {
Log.d(TAG, "on page progress changed and progress is " + newProgress);
// 进度是100就代表dom树加载完成了
if (newProgress == 100) {

}
}
}
mWebView.webViewClient = object : WebViewClient() {

override fun onPageFinished(view: WebView, url: String) {
super.onPageFinished(view, url)
Log.d(TAG, "onPageFinished")
// 这里分开写为了便于理解,休眠两秒的逻辑是为了等待章节列表加载完毕再解析页面。下面会写在一起。
// 第一个 view.loadUrl:获取到 id 为 all_mores1 的 <a> 标签,然后执行点击。
// 第二个 view.loadUrl:在 InJavaScriptLocalObj 中解析页面。
view.loadUrl("javascript:function aa({document.getElementById('all_mores1').click();};aa();")
Thread.sleep(2000)
view.loadUrl("javascript:window.local_obj.showSource('<head>'+document.getElementsByTagName('html')[0].innerHTML+'</head>','test');")
}
}
mWebView.loadUrl(url)
}

private inner class InJavaScriptLocalObj() {
@JavascriptInterface
fun showSource(html: String, test: String) {
val document = Jsoup.parse(html)
...
}
}

有时拿到的属性值不单单是个 url 地址,还会包含其他的内容(比如这样:background-image: url(https://...))。可以通过如下方法过滤出 url。

1
2
3
4
5
6
7
8
9
fun findUrlByStr(data: String): String {
val pattern =
Pattern.compile("https?://[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]");
val matcher = pattern.matcher(data);
if (matcher.find()) {
return matcher.group();
}
return "";
}

当进入到详情页时,漫画类网站有些是需要滚动到页面底部才会加载图片。这时可以通过 WebView 来模拟浏览器行为。(这里,我动态创建的 WebView 执行 js 代码来滚动页面无效,所以在 xml 里创建了一个。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
override fun onAdapterListener(position: Int) {
Log.d(TAG, "显示详细内容:" + resultUrl + seriesList[position].url)

wv.settings.javaScriptEnabled = true
wv.addJavascriptInterface(MyJavaScriptLocalObj(), "local_obj")
wv.webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView, newProgress: Int) {
Log.d(TAG, "on page progress changed and progress is " + newProgress)
// 进度是100就代表dom树加载完成了
if (newProgress == 100) {
// 这里写到一起了。每 100 毫秒执行一次页面滚动到底部,然后根据页面能够提供的信息,比如可以拿到页数,判断页数到底后,跳出循环,执行解析。
view.loadUrl("javascript:function aa(){var interal = setInterval(function () {var ll = document.getElementById('js_staticPage').innerText.split('/');window.scrollTo(0,document.body.scrollHeight);if(ll[0] == ll[1]){window.local_obj.showSource('<head>'+document.getElementsByTagName('html')[0].innerHTML+'</head>');clearInterval(interal)}}, 100)};aa();")
}
}
}
wv.webViewClient = WebViewClient()
wv.loadUrl(resultUrl + seriesList[position].url)
}

private inner class MyJavaScriptLocalObj() {

@JavascriptInterface
fun showSource(html: String) {
val document = Jsoup.parse(html)
...
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:orientation="vertical"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">

...
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">

<WebView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/wv"
android:visibility="invisible"
/>

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_comic"
android:layout_width="match_parent"
android:layout_height="match_parent"/>

</FrameLayout>
</LinearLayout>

比如影视类:

影视类一般需要获取到视频的 url 地址

通过 WebViewClient 的 shouldInterceptRequest() 可以尝试过滤得到视频 url,有了视频地址便可以通过 ExoPlayer 或者 GSYPlayer 等来执行播放的逻辑了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private lateinit var mWebView: WebView

private fun parseWebView() {
mWebView = WebView(this)
mWebView.settings.javaScriptEnabled = true
mWebView.webChromeClient = WebChromeClient()
mWebView.webViewClient = object : WebViewClient() {

override fun shouldInterceptRequest(view: WebView?, url: String): WebResourceResponse? {
// 过滤出视频格式url
if (url.contains(".mp4") || url.contains(".m3u8") || url.contains(".avi") ||
url.contains(".mov") || url.contains(".mkv") || url.contains(".flv") ||
url.contains(".f4v") || url.contains(".rmvb")
) {
Log.d(TAG, "$url")
runOnUiThread {
videoPlay(url)
}
}
return super.shouldInterceptRequest(view, url)
}
}
mWebView.loadUrl(url)
}

如果视频地址写在 script 标签中,具体解析看网页结构。大体上就是先拿到 script 标签内容,然后通过 split 分割等,过滤出 url。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
    private fun aaa(url : String){
Thread {
val document = Jsoup.connect(url).get()
Log.d(TAG,"document: $document")

val script = document.select("script")
Log.d(TAG,"scripe: $script")
for(e in script){
val data = e.data().toString().split("var")
for(w in data){
if (w.contains("=")){
if (w.contains("video")){
val vv = w.split("\"")
for(q in vv){
if (q.contains(".mp4") || q.contains(".m3u8") || q.contains(".avi") ||
q.contains(".mov") || q.contains(".mkv") || q.contains(".flv") ||
q.contains(".f4v") || q.contains(".rmvb")
) {
Log.d(TAG, "过滤的地址 q:"+"$q")
runOnUiThread {
videoPlay(q)
}
break
}
}
}
}
}
}
}.start()
}

如果得到的视频地址是 m3u8 格式或者是 mp4 格式,可以通过 ExoPlayer 或者 GSYPlayer 直接播放。但许多网站其实是 blog url 格式的(blob:https://),这个还没有找到解析的办法。(有一种方式感觉成功几率不高,并且也不是由代码解决的。记录一下,就是在 F12 检查时,在 video 标签内插入 a 标签的方式。)

注意:

1、虽然得到 video src 的值为 blog 格式,但是在WebView中依然可以拦截得到.m3u8格式。

2、m3u8视频格式简介

  • m3u8视频格式原理:将完整的视频拆分成多个 .ts 视频碎片,.m3u8 文件详细记录每个视频片段的地址。
  • 视频播放时,会先读取 .m3u8 文件,再逐个下载播放 .ts 视频片段。
  • 常用于直播业务,也常用该方法规避视频窃取的风险。加大视频窃取难度。

其他

报错

org.jsoup.UnsupportedMimeTypeException: Unhandled content type. Must be text/, application/xml, or application/+xml. Mimetype=video/mp4, URL=””

原因:可能是请求头里面的请求类型(ContextType)不符合要求。

解决:只需要在 Connection con = Jsoup.connect(url); 中添加 ignoreContentType(true) 即可,意思就是忽略ContextType 的检查。

1
val document = Jsoup.connect(url).ignoreContentType(true).get()

备注

参考资料

易百教程

WIKI教程

欢迎关注微信公众号:非也缘也