前言
好几年前写过一篇 Java 的爬虫文章,好像是我部落格内最受欢迎的一篇...
时过境迁,Eclipse 退流行了、Java 出到 15 了,加上一些因素
打算把爬虫文章重新写过一遍,这次会当是做一个 Site project,尽量把内容充实一点
预计这系列文章应该会写个五六篇以上,后面也会教到资料库及图形化介面
必备知识
常用 Http request Ex. get postCookie 相关知识HTML相关知识CSS 选择器相关知识Java OOP 相关知识使用环境
Intelli JChrome创建专案
这次我们使用的 IDE 是 IntelliJ,点下 New Project
选择 Maven 专案,SDK 我是安装最新的 15 版
专案要放哪边就依照个人喜好,都完成后就会显示初始画面
新增 OkHttp
既然是爬虫,就需要可以发送 Http request 的套件
上一篇文章使用的是 Apache 的 HttpClient
这次尝试使用新的 OkHttp,这两种套件效能其实差不多
一样看个人喜好使用
将以下内容加入到 pom.xml 中
<dependencies> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>3.3.1</version> </dependency></dependencies>
点开右边的 Maven 选单,按下左上角的 Reload All Maven Projects 按钮
安装会需要一点时间,跑完后就可以看到套件安装完毕
相关 Class
这次我们把目标放在鼎鼎大名的 PTT 八卦板上
所以先简单开几个 Class
ptt.crawler.model.Boardptt.crawler.model.Articleptt.crawler.config.Cofingptt.crawler.ReaderBoard.java
这只档案用来储存 PTT 中的看板资讯
package ptt.crawler.model;public class Board { private String url; // 看板网址 private String nameCN; // 中文名称 private String nameEN; // 英文名称 private Boolean adultCheck; // 成年检查 public Board(String url, String nameCN, String nameEN, Boolean adultCheck) { this.url = url; this.nameCN = nameCN; this.nameEN = nameEN; this.adultCheck = adultCheck; } public String getUrl() { return url; } public String getNameCN() { return nameCN; } public String getNameEN() { return nameEN; } public Boolean getAdultCheck() { return adultCheck; }}
Article.java
这只档案用来储存 PTT 中的文章资讯
有读者可能会留意到建构子中怎么没有 body
因为目前创建 Article 的时候并不会有文章内容,所以就先不写
package ptt.crawler.model;import java.util.*;public class Article { private Board parent; // 所属板块 private String url; // 网址 private String title; // 标题 private String body; // 内容 private String author; // 作者 private Date date; // 发文时间 public Article(Board parent, String url, String title, String author, Date date) { this.parent = parent; this.url = url; this.title = title; this.author = author; this.date = date; } public Board getParent() { return parent; } public String getUrl() { return url; } public String getTitle() { return title; } public String getBody() { return body; } public void setBody(String body) { this.body = body; } public String getAuthor() { return author; } public Date getDate() { return date; } @Override public String toString() { return String.format("Article{ url='%s', title='%s', body='%s', author='%s', date='%s' }", url, title, body, author, date); }}
Config.java
这只档案用来放一些参数,比如看板列表、网址...等
看板列表目前先用写死的方式,之后如果有使用到资料库,再来重构这部分
package ptt.crawler.config;import ptt.crawler.model.Board;import java.util.*;public final class Config { public static final String PTT_URL = "https://www.ptt.cc"; public static final Map<String, Board> BOARD_LIST = new HashMap<>() {{ /* PTT 看板网址等于看板英文名称,故直接使用英文名称当 Map 的 Key Ex. 八卦板 Gossiping,网址为 https://www.ptt.cc/bbs/Gossiping/index.html 股板 Stock,网址为 https://www.ptt.cc/bbs/Stock/index.html */ put("Gossiping", new Board( "/bbs/Gossiping", "八卦板", "Gossiping", true) ); }};}
Reader.java
这只档案是爬虫的主程式,下一节会详细的解说当中的程式码
正篇
先来看看基本的架构
package ptt.crawler;import org.jsoup.select.Elements;import ptt.crawler.model.*;import ptt.crawler.config.Config;import okhttp3.*;import org.jsoup.*;import org.jsoup.nodes.*;import java.io.IOException;import java.text.ParseException;import java.text.SimpleDateFormat;import java.util.*;public class Reader { private OkHttpClient okHttpClient; private final Map<String, List<Cookie>> cookieStore; // 保存 Cookie private final CookieJar cookieJar; public Reader() throws IOException { /* 初始化 */ cookieStore = new HashMap<>(); cookieJar = new CookieJar() { /* 保存每次伺服器端回传的 Cookie */ @Override public void saveFromResponse(HttpUrl httpUrl, List<Cookie> list) { List<Cookie> cookies = cookieStore.getOrDefault( httpUrl.host(), new ArrayList<>() ); cookies.addAll(list); cookieStore.put(httpUrl.host(), cookies); } /* 每次发送带上储存的 Cookie */ @Override public List<Cookie> loadForRequest(HttpUrl httpUrl) { return cookieStore.getOrDefault( httpUrl.host(), new ArrayList<>() ); } }; okHttpClient = new OkHttpClient.Builder().cookieJar(cookieJar).build(); /* 获得网站的初始 Cookie */ Request request = new Request.Builder().get().url(Config.PTT_URL).build(); okHttpClient.newCall(request).execute(); }}
目前整个 Reader 只会用到以下这三个共用实体
okHttpClient 为 Reader 共用的请求实体cookieStore 为 Cookie 保存使用,每一次发送请求都会带着cookieJar 为 OkHttp 管理 Cookie 用的 Class建构子内除了实体化部分还另外做了一件事情「获得网站的初始 Cookie」
这么做的用意在于,除了尽量模拟正常使用者的使用环境外
也顾虑到网站可能会把一些验证资讯放到 Cookie 内
如果没带到这些内容可能造成验证失败
PS: 这一步并非必要,只是一种习惯而已
第一只 Method
接下来来分析一下如果一个从未访问过 PTT 八卦板 的浏览器会遇到什么事情
直接开启 Chrome 的无痕模式访问八卦板网址
PS: 养成使用无痕的习惯,用以确认爬虫真实遇到的情况
看到的第一个页面是一个成年检查画面,有两个按钮让我们选择
遇到这种状况不需要慌张,按下 F12 开启开发者工具页面
PS: 开发者工具很有用,Web 开发者或是网站爬虫开发者都要熟悉操作
点下左上方的 icon,反白后再去点选同意按钮
可以看到右方帮你锁定到了同意按钮在 HTML 中的位置
来看看这颗按钮按下去会发生什么事情
首先可以看到按钮是包在一个 Form 表单里面
这个表单的方式是 Post,目标 URL 是要传送到 /ask/over18
再来可以看到有一个隐藏的栏位,name = form,value 等于八卦板的网址
这边可以大胆判断对方的逻辑是透过这个栏位来决定使用者点下同意按钮后该跳转去哪
PS: 这样的方法如果没有做好安全检查的话,对于资安上可能是一个小漏洞
同意按钮本身就是一个 submit,name = yes、value = yes
一顿分析下来可以得到结果了,点下同意按钮后的行为如下
发送 post 表单到网址 https://www.ptt.cc/ask/over18一併带过去的资料有两个 栏位from 及 栏位yes那么第一只 Method 就来写这部分吧,直接上程式码
/* 进行年龄确认 */private void runAdultCheck(String url) throws IOException { FormBody formBody = new FormBody.Builder() .add("from", url) .add("yes", "yes") .build(); Request request = new Request.Builder() .url(Config.PTT_URL + "/ask/over18") .post(formBody) .build(); okHttpClient.newCall(request).execute();}
这个方法需要传入一个 url 字串,用来代表 form 栏位的值
formBody 的部分利用套件提供的方法直接帮我们处理完毕
request 也是一样,利用套件帮我们处理好,post 方法代表我们要用 post 发送,很直觉
最后呼叫 newCall 创建一个请求,呼叫 execute 发送这一个请求
PS: execute 方法会回传一个 Response 物件,但我们不需要针对回传内容做处理
至于好奇心比较重的读者们可能会有疑惑说:「为什么这样就有效果了呢?」
答案其实很单纯,因为 PTT 确认使用者有没有点击同意按钮的方法呢
就是检查 Cookie 中有没有一个叫做 over18 的内容
这张图片是一开始访问 PTT 时的 Cookie 列表
按下同意按钮之后,Cookie 列表就多了几条,其中一条就是 over18
所以只要带上有 over18 的 Cookie, PTT 就不会跳转到成年检查页面
第二只 Method
通过成年检查后后就可以看到一个正常的列表画面
接下来确认文章列表是怎么呈现的,一样利用开发者工具锁定到文章列表上
文章列表的每一条都是一个 div,class 都是设定 r-ent
再来看看 div 的内部长怎样
标题 div,class 为 title,里面还有一个 a
作者 div,class 为 author
日期 div,class 为 date
知道该找的资料在何方,那么就可以来写第二只 Method
但在此之前需要来安装另一个好用的套件 Jsoup
这个套件可以解析 HTML 结构,之后利用 CSS 选择器的方式来提取需要的资料
比自己用 Split 去切字串来的方便很多,大力推荐
安装方式就跟安装 OkHttp 一样,在 pom.xml 中加入以下程式码
点开右边的 Maven 选单,按下左上角的 Reload All Maven Projects 按钮
<dependency> <groupId>org.jsoup</groupId> <artifactId>jsoup</artifactId> <version>1.11.3</version></dependency>
安装好后就来看看第二只 Method 的内容
/* 解析看板文章列表 */private List<Map<String, String>> parseArticle(String body) { List<Map<String, String>> result = new ArrayList<>(); Document doc = Jsoup.parse(body); Elements articleList = doc.select(".r-ent"); for (Element element: articleList) { String url = element.select(".title a").attr("href"); String title = element.select(".title a").text(); String author = element.select(".meta .author").text(); String date = element.select(".meta .date").text(); result.add(new HashMap<>(){{ put("url", url); put("title", title); put("author", author); put("date", date); }}); } return result;}
这一只 Method 主要负责解析出需要的资料,传入 HTML 回传一个 List
首先将原始的 HTML 字串交给 Jsoup 转换成 Document
Document doc = Jsoup.parse(body);
再来就直接把所有的文章取出,利用跟 CSS 选择器一样的方式
articleList 中存放的就是每一个 class 为 r-ent 的 div
Elements articleList = doc.select(".r-ent");
最后利用迴圈把上面分析出来的资讯过滤出来,这部分应该不需要多做解释
写过前端的应该很眼熟,上手很快
for (Element element: articleList) { String url = element.select(".title a").attr("href"); String title = element.select(".title a").text(); String author = element.select(".meta .author").text(); String date = element.select(".meta .date").text(); result.add(new HashMap<>(){{ put("url", url); put("title", title); put("author", author); put("date", date); }});}
至于为什么不直接回传 List<Article> 而要另外存到 Map 中呢?
主要是因为这只 Method 只是负责解析资料,如果要由 Method 来创造 Model
感觉就违反单一原则,所以只回传一个 Map,由其它地方来处理
PS: 这只是一种 Coding style 而不是準则,所以不需要太计较
第三只 Method
这是 Reader 的最后一只 Method,用来让外部呼叫的入口
当中没有特别需要讲解的部分,都是很简单的逻辑
public List<Article> getList(String boardName) throws IOException, ParseException { Board board = Config.BOARD_LIST.get(boardName); /* 如果找不到指定的看板 */ if (board == null) { return null; } /* 如果看板需要成年检查 */ if (board.getAdultCheck() == true) { runAdultCheck(board.getUrl()); } /* 抓取目标页面 */ Request request = new Request.Builder() .url(Config.PTT_URL + board.getUrl()) .get() .build(); Response response = okHttpClient.newCall(request).execute(); String body = response.body().string(); /* 转换 HTML 到 Article */ List<Map<String, String>> articles = parseArticle(body); List<Article> result = new ArrayList<>(); SimpleDateFormat simpleDateFormat = new SimpleDateFormat("MM/dd"); for (Map<String, String> article: articles) { String url = article.get("url"); String title = article.get("title"); String author = article.get("author"); Date date = simpleDateFormat.parse(article.get("date")); result.add(new Article(board, url, title, author, date)); } return result;}
完整程式码
package ptt.crawler;import org.jsoup.select.Elements;import ptt.crawler.model.*;import ptt.crawler.config.Config;import okhttp3.*;import org.jsoup.*;import org.jsoup.nodes.*;import java.io.IOException;import java.text.ParseException;import java.text.SimpleDateFormat;import java.util.*;public class Reader { private OkHttpClient okHttpClient; private final Map<String, List<Cookie>> cookieStore; // 保存 Cookie private final CookieJar cookieJar; public Reader() throws IOException { /* 初始化 */ cookieStore = new HashMap<>(); cookieJar = new CookieJar() { @Override public void saveFromResponse(HttpUrl httpUrl, List<Cookie> list) { List<Cookie> cookies = cookieStore.getOrDefault( httpUrl.host(), new ArrayList<>() ); cookies.addAll(list); cookieStore.put(httpUrl.host(), cookies); } /* 每次发送带上储存的 Cookie */ @Override public List<Cookie> loadForRequest(HttpUrl httpUrl) { return cookieStore.getOrDefault( httpUrl.host(), new ArrayList<>() ); } }; okHttpClient = new OkHttpClient.Builder().cookieJar(cookieJar).build(); /* 获得网站的初始 Cookie */ Request request = new Request.Builder().get().url(Config.PTT_URL).build(); okHttpClient.newCall(request).execute(); } public List<Article> getList(String boardName) throws IOException, ParseException { Board board = Config.BOARD_LIST.get(boardName); /* 如果找不到指定的看板 */ if (board == null) { return null; } /* 如果看板需要成年检查 */ if (board.getAdultCheck() == true) { runAdultCheck(board.getUrl()); } /* 抓取目标页面 */ Request request = new Request.Builder() .url(Config.PTT_URL + board.getUrl()) .get() .build(); Response response = okHttpClient.newCall(request).execute(); String body = response.body().string(); /* 转换 HTML 到 Article */ List<Map<String, String>> articles = parseArticle(body); List<Article> result = new ArrayList<>(); SimpleDateFormat simpleDateFormat = new SimpleDateFormat("MM/dd"); for (Map<String, String> article: articles) { String url = article.get("url"); String title = article.get("title"); String author = article.get("author"); Date date = simpleDateFormat.parse(article.get("date")); result.add(new Article(board, url, title, author, date)); } return result; } /* 进行年龄确认 */ private void runAdultCheck(String url) throws IOException { FormBody formBody = new FormBody.Builder() .add("from", url) .add("yes", "yes") .build(); Request request = new Request.Builder() .url(Config.PTT_URL + "/ask/over18") .post(formBody) .build(); okHttpClient.newCall(request).execute(); } /* 解析看板文章列表 */ private List<Map<String, String>> parseArticle(String body) { List<Map<String, String>> result = new ArrayList<>(); Document doc = Jsoup.parse(body); Elements articleList = doc.select(".r-ent"); for (Element element: articleList) { String url = element.select(".title a").attr("href"); String title = element.select(".title a").text(); String author = element.select(".meta .author").text(); String date = element.select(".meta .date").text(); result.add(new HashMap<>(){{ put("url", url); put("title", title); put("author", author); put("date", date); }}); } return result; }}
测试
这边要使用的测试套件是 JUnit ,使用上很简单
一样先装套件
<dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> <version>5.8.0-M1</version> <scope>test</scope></dependency>
再将输入焦点放到 Class 那一行,按下键盘的 Alt + Enter
跳出选单后选择 Create Test 后按下 Entrt
Testing library 选择 JUnit5
setUp/@Before 打勾,按下 OK
IDE 就会帮你在 test 资料夹中建立好测试的 Class
之后将以下程式码覆盖原本的内容
package ptt.crawler;import org.junit.jupiter.api.Assertions;import org.junit.jupiter.api.Test;import ptt.crawler.model.Article;import java.io.IOException;import java.text.ParseException;import java.util.List;class ReaderTest { private Reader reader; @org.junit.jupiter.api.BeforeEach void setUp() { try { reader = new Reader(); } catch (IOException e) { e.printStackTrace(); } } @Test void list() { try { List<Article> result = reader.getList("Gossiping"); Assertions.assertInstanceOf(List.class, result); System.out.println(result); } catch (IOException e) { e.printStackTrace(); } catch (ParseException e) { e.printStackTrace(); } }}
之后第 24 行旁边应该会出现一个绿色箭头,按下去后选择 Run 'list()'
跑完后就可以看到文章列表的内容
PS: 这边没有讲到 JUnit 的用法,有兴趣的读者可以自行去了解
如果觉得 Junit 用法上很麻烦的读者,也可以直接开一个 Class 去 Run
效果是一样的,任君挑选
package ptt.crawler;import ptt.crawler.model.Article;import java.io.IOException;import java.text.ParseException;import java.util.List;public class Test { public static void main(String[] args) { try { Reader reader = new Reader(); List<Article> result = reader.getList("Gossiping"); System.out.println(result); } catch (IOException e) { e.printStackTrace(); } catch (ParseException e) { e.printStackTrace(); } }}
后记
首先很感谢将文章看到这边,此系列旨意在于带领想要了解爬虫的读者们入门
爬虫说白了就是将平常使用者的操作自动化而已,没有想像中的複杂
这次选 PTT 来当範例主要也是因为 PTT 是一个很经典的 Web 架构
没有前后端分离、内容简单、验证功能几乎没有,是一个很好的範例
之后的几篇会继续扩充其他功能,敬请期待
本文章同步张贴于 本人部落格
觉得这篇文章有帮助到你的话,请帮我点个 Like