Jsoup

Jsoup是Java的HTML解析器,可以通过类似jQuery的操作方法来解析出DOM结构中你需要的数据,对Android端而言,利用Jsoup可以给大部分网站做第三方客户端。
Jsoup最强大的莫过于它的元素选择器了,通过筛选语法可以获取到任意你需要的DOM树中的元素,下面是官方文档中的选择器语法:

选择器概要(Selector overview)

  • Tagname:通过标签查找元素(例如:a)
  • ns|tag:通过标签在命名空间查找元素,例如:fb|name查找元素
  • id:通过ID查找元素,例如#logo
  • .class:通过类型名称查找元素,例如.masthead
  • [attribute]:带有属性的元素,例如[href]
  • [^attr]:带有名称前缀的元素,例如[^data-]查找HTML5带有数据集(dataset)属性的元素
  • [attr=value]:带有属性值的元素,例如[width=500]
  • [attr^=value],[attr$=value],[attr=value]:包含属性且其值以value开头、结尾或包含value的元素,例如[href=/path/]
  • [attr~=regex]:属性值满足正则表达式的元素,例如img[src~=(?i).(png|jpe?g)]
  • :所有元素,例如*

选择器组合方法

  • el#id::带有ID的元素ID,例如div#logo
  • el.class:带类型的元素,例如. div.masthead
  • el[attr]:包含属性的元素,例如a[href]
  • 任意组合:例如a[href].highlight
  • ancestor child:继承自某祖(父)元素的子元素,例如.body p查找“body”块下的p元素
  • parent > child:直接为父元素后代的子元素,例如: div.content > pf查找p元素,body > * 查找body元素的直系子元素
  • siblingA + siblingB:查找由同级元素A前导的同级元素,例如div.head + div
  • siblingA ~ siblingX:查找同级元素A前导的同级元素X例如h1 ~ p
  • el, el, el:多个选择器组合,查找匹配任一选择器的唯一元素,例如div.masthead, div.logo

伪选择器(Pseudo selectors)

  • :lt(n):查找索引值(即DOM树中相对于其父元素的位置)小于n的同级元素,例如td:lt(3)
  • :gt(n):查找查找索引值大于n的同级元素,例如div p:gt(2)
  • :eq(n) :查找索引值等于n的同级元素,例如form input:eq(1)
  • :has(seletor):查找匹配选择器包含元素的元素,例如div:has(p)
  • :not(selector):查找不匹配选择器的元素,例如div:not(.logo)
  • :contains(text):查找包含给定文本的元素,大小写铭感,例如p:contains(jsoup)
  • :containsOwn(text):查找直接包含给定文本的元素
  • :matches(regex):查找其文本匹配指定的正则表达式的元素,例如div:matches((?i)login)
  • :matchesOwn(regex):查找其自身文本匹配指定的正则表达式的元素
    注意:上述伪选择器是0-基数的,亦即第一个元素索引值为0,第二个元素index为1等

分析

今天是主角是我平时经常去逛的V2EX
首先看V2EX网站某个板块的结构,呈列表状,那么只要能解析其中一个item的结构即可

找到其中一个item对应的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<div class="cell item" style="">
<table cellpadding="0" cellspacing="0" border="0" width="100%">
<tr>
<td width="48" valign="top" align="center"><a href="/member/qile1"><img src="//cdn.v2ex.co/gravatar/03b6474fdca2de3813a9860d19acdaf8?s=48&d=retro" class="avatar" border="0" align="default" /></a></td>
<td width="10"></td>
<td width="auto" valign="middle"><span class="item_title"><a href="/t/336709#reply10">threading 线程间通信如何控制线程运行及等待。</a></span>
<div class="sep5"></div>
<span class="small fade"><div class="votes"></div><a class="node" href="/go/python">Python</a> &nbsp;•&nbsp; <strong><a href="/member/qile1">qile1</a></strong> &nbsp;•&nbsp; 22 分钟前 &nbsp;•&nbsp; 最后回复来自 <strong><a href="/member/sheep3">sheep3</a></strong></span>
</td>
<td width="70" align="right" valign="middle">
<a href="/t/336709#reply10" class="count_livid">10</a>
</td>
</tr>
</table>
</div>

从上面的代码中可以看出,能获取到的信息有:

  • 头像url
  • 发帖者昵称
  • 最后回复时间
  • 最后回复者
  • 帖子所属节点
  • 回帖数
  • 帖子标题
  • 帖子ID

定义与之对应的Java Bean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class TopicListBean {
private String imgUrl;
private String name;
private String updateTime;
private String lastUser;
private String node;
private int commentNum;
private String title;
private String topicId;
}

好了,接下来就是通过筛选语法定义HTML DOM -> Java Bean的映射关系了

以解析标题为例子,需要筛选出的目标内容为<a href="/t/336709#reply10">threading 线程间通信如何控制线程运行及等待。</a>

  • 按照筛选语法的定义,带有class属性的标签可以通过el.class的方式引用,所以最外层的<div class="cell item" style="">即写为div.cell item,但筛选语法规定class中不能出现空格,空格需要由.代替,所以需要改为div.cell.item
  • 然后语法规定,继承自某祖(父)元素的子元素可以通过空格的方式获取,那么子元素层层深入,即div.cell.item table tr td
  • 最后由title所在的span的class,获取该span,即div.cell.item table tr td span.item_title,取它的直接子元素a标签,即div.cell.item table tr td span.item_title > a

同理写出其他元素的筛选语法:

1
2
3
4
5
6
div.cell.item table tr td span.item_title > a //标题
div.cell.item table tr td img.avatar //头像
div.cell.item table tr span.small.fade a.node //节点
div.cell.item table tr a.count_livid //评论数
div.cell.item table tr span.small.fade strong a //作者 & 最后回复
div.cell.item table tr span.small.fade //更新时间

解析

这里开始,就要利用Jsoup的API来完整真正的解析了:

  • Document dom = Jsoup.connect(host_url).timeout(10000).get();,首先获取目标Web页面的整个DOM结构,这里需要传入url地址和连接超时时间,注意这一步由于是网络请求,需要在子线程中执行
  • Elements itemElements = doc.select("div.cell.item");根据上面的分析,这一步会从DOM中筛选出所有的目标item,itemElements是这些item的List集合
  • Elements titleElements = itemElements.get(i).select("div.cell.item table tr td span.item_title > a");从List中取第i个item,并依照之前的分析,取到包含“标题“信息的a标签
  • String title = titleElements.get(0).attr("href"),取该a标签中href属性的值,即标题
  • 如果需要的字段不是标签的属性,而是标签的内容,比如解析帖子节点<a class="node" href="/go/python">Python</a>,需要获取的是a标签的内容Python,应该调用text方法,String node = nodeElements.get(0).text()

注意事项

1.在取最后回复者时需要注意,其父标签span下包含两个strong标签<strong><a href="/member/qile1">qile1</a></strong> &nbsp;•&nbsp; 22 分钟前 &nbsp;•&nbsp; 最后回复来自 <strong><a href="/member/sheep3">sheep3</a></strong>,前者是最后回复时间,后者是最后回复者,且两个标签没有id或class等标志,如果按照div.cell.item table tr span.small.fade strong a这种筛选器语法来筛选的话会一次取到两个strong中的a标签,所以仅想获取后者的话需要这样写来nameElements.get(1).text()取第二项

2.在测试时发现解析有时会crash,调查后发现V2EX站点的item结构不是固定的,当帖子无人回复时DOM结构会不同,没有最后回复者、最后回复时间、评论数这三个信息,所以取这三条信息时要记得做额外判断

1
2
3
4
5
6
7
8
9
10
//存在没有 最后回复者、评论数、更新时间的情况
if (nameElements.size() > 1) {
bean.setLastUser(nameElements.get(1).text());
}
if (commentElements.size() > 0) {
bean.setCommentNum(Integer.valueOf(commentElements.get(0).text()));
}
if (timeElements.size() > 1) {
bean.setUpdateTime(parseTime(timeElements.get(1).text()));
}

最终代码

配合RxJava,最终代码如下,这里也能看到RxJava用起来真的方便、结构清晰:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
Observable.just(VtexApis.TAB_HOST + type)
.subscribeOn(Schedulers.io())
.map(new Func1<String, Document>() {
@Override
public Document call(String s) {
try {
return Jsoup.connect(s).timeout(10000).get();
} catch (IOException e) {
LogUtil.d(e.toString());
e.printStackTrace();
}
return null;
}
})
.filter(new Func1<Document, Boolean>() {
@Override
public Boolean call(Document document) {
return document != null;
}
})
.map(new Func1<Document, List<TopicListBean>>() {
@Override
public List<TopicListBean> call(Document doc) {
List<TopicListBean> mList = new ArrayList<>();
Elements itemElements = doc.select("div.cell.item"); //item根节点
int count = itemElements.size();
for (int i = 0; i < count; i++) {
Elements titleElements = itemElements.get(i).select("div.cell.item table tr td span.item_title > a"); //标题
Elements imgElements = itemElements.get(i).select("div.cell.item table tr td img.avatar"); //头像
Elements nodeElements = itemElements.get(i).select("div.cell.item table tr span.small.fade a.node"); //节点
Elements commentElements = itemElements.get(i).select("div.cell.item table tr a.count_livid"); //评论数
Elements nameElements = itemElements.get(i).select("div.cell.item table tr span.small.fade strong a"); //作者 & 最后回复
Elements timeElements = itemElements.get(i).select("div.cell.item table tr span.small.fade"); //更新时间
TopicListBean bean = new TopicListBean();
if (titleElements.size() > 0) {
bean.setTitle(titleElements.get(0).text());
bean.setTopicId(parseId(titleElements.get(0).attr("href")));
}
if (imgElements.size() > 0) {
bean.setImgUrl(parseImg(imgElements.get(0).attr("src")));
}
if (nodeElements.size() > 0) {
bean.setNode(nodeElements.get(0).text());
}
if (nameElements.size() > 0) {
bean.setName(nameElements.get(0).text());
}
//存在没有 最后回复者、评论数、更新时间的情况
if (nameElements.size() > 1) {
bean.setLastUser(nameElements.get(1).text());
}
if (commentElements.size() > 0) {
bean.setCommentNum(Integer.valueOf(commentElements.get(0).text()));
}
if (timeElements.size() > 1) {
bean.setUpdateTime(parseTime(timeElements.get(1).text()));
}
mList.add(bean);
}
return mList;
}
})
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Action1<List<TopicListBean>>() {
@Override
public void call(List<TopicListBean> mList) {
mView.showContent(mList);
}
}, new Action1<Throwable>() {
@Override
public void call(Throwable throwable) {
mView.showError("数据加载失败");
}
});

运行效果

项目地址GeekNews

收工跑路~٩(ˊᗜˋ*)و

参考文章

使用 jsoup 对 HTML 文档进行解析和操作
jsoup select 选择器

声明:本站所有文章均为原创或翻译,遵循署名-非商业性使用-禁止演绎 4.0 国际许可协议,如需转载请确保您对该协议有足够了解,并附上作者名(Est)及原贴地址