若是你想利用本身的技術作出一點有意思的產品來,那麼爬蟲、算法和 AI 等技術多是一個不錯的突破口。今天,咱們就來介紹下使用 Java 爬取頁面信息的幾種思路。html
提及爬蟲,自從 Python 興起以後,人們可能更多地使用 Python 進行爬蟲. 畢竟,Python 有許多封裝好的庫。但對於 Javaer,若是你以爲學習 Python 成本比較高的話,使用 Java 也是一個不錯的選擇,尤爲是當你但願在客戶端進行爬蟲的時候。前端
在這篇文中咱們會以幾個頁面爬取的小例子來介紹使用 Java 進行爬蟲的幾種經常使用的手段。java
也許有許多人會像我同樣,喜歡 Star 各類有趣的項目,但願某天用到的時候再看。但當你 Star 的項目太多的時候,想要再尋找以前的某個項目就會比較困難。由於尋找項目的時候必須在本身的 Star 列表中一頁一頁地去翻(國內訪問 Github 的速度還比較慢)。你也能夠用筆記整理下本身 Star 的項目,但這種重複性的工做,超過三次,就應該考慮用程序來解決了。此外,咱們但願尋找本身 Star 過的項目的時候可以像檢索數據庫記錄同樣一個 SQL 搞定。因此,咱們能夠直接爬取 Star 列表並存儲到本地數據庫,而後咱們就能夠對這些數據隨心所欲了不是?react
下面咱們開始進行頁面信息的抓取。git
抓取頁面信息以前咱們首先要作的是分析頁面的構成。咱們可使用 Chrome 來幫助咱們解決這個問題。方式很簡單,打開 Chrome,登入本身的 Github,而後點擊頁面的 Star 的 Tab 便可。而後,咱們在本身的 Star 列表的一個條目上面進行 "檢查" 便可,github
如圖所示,頁面的一個 <div>
標籤就對應了列表中的一個元素。咱們可使用 Jsoup 直接抓取到這個標籤,而後對標籤的子元素信息進行提取。這樣就能夠將整個列表中的信息所有檢索出來。web
頁面分析的另外一個問題是頁面的自動切換,即,由於 Star 列表是分頁的,一個頁面信息加載完畢以後咱們須要讓程序自動跳轉到下一頁進行加載。對於 Github,這個要求是很容易知足的。由於 Github 的 Star 頁面徹底由服務端渲染完畢以後返回的,因此咱們能夠在頁面中直接找到下一頁的連接。算法
如圖所示,咱們直接在頁面的 Next 按鈕上面進行 "檢查" 就能夠看到,Next 按鈕是一個 <a>
標籤,這裏直接包含了下一個頁面的連接。sql
頁面信息的分析,咱們使用 Jsoup 來搞定。Jsoup 是一個基於 Java 的 HTML 解析器。咱們能夠直接經過在 pom.xml 中引入依賴來使用它,數據庫
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.11.3</version>
</dependency>
複製代碼
這裏咱們使用 Maven 來做爲項目的構建工具。若是你但願使用 Gradle 的話,稍微作下轉換也是能夠的。對於 Jsoup 的使用方式,你能夠在其官方網站中進行了解:jsoup.org/.
而後,咱們須要考慮的是數據的存儲的問題。若是爬蟲的數據量比較大、對數據庫性能要求比較高的話,你可使用 MySQL 和數據庫鏈接池來提高讀寫性能。這裏咱們使用一種簡單、輕量的數據庫 H2. H2 是一個小型嵌入式數據庫,它開源、純java實現,是關係數據庫,小巧且方便,很是適合咱們的應用場景。
參考下面的步驟進行安裝:在windows上安裝H2數據庫。
而後按照說明的方式打開便可。若是打開的時候發生了錯誤,須要檢查下是不是端口被佔用的問題。可使用 H2 Console (Command line)
來打開。若是確實是由於端口占用的問題,參考下面的步驟結束佔用端口的程序便可,如何查看某個端口被誰佔用。
這裏咱們使用 IDEA 做爲開發工具,Maven 做爲構建工具。
首先,咱們須要引入項目所需的各類依賴。上面咱們已經介紹了 Jsoup,爲了在項目中使用 H2 數據庫,咱們還要引入 H2 的數據庫驅動,
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.197</version>
</dependency>
複製代碼
數據庫的讀寫有許多封裝的庫,好比經常使用的 ORM 框架,Hibernate 和 Mybatis 等。這裏咱們使用原生的數據庫讀取方式,由於咱們的項目比較小而且熟悉這些底層的東西更有益於咱們學習和理解上述框架。因此,目前爲止,咱們須要引用的依賴總計就兩個。
而後就是編寫代碼了。這裏,咱們首先考慮項目總體的結構。咱們沒有直接使用消費者生產者模式,而是建立一個由 6 條線程組成的線程池,其中 1 個線程用來作觀察,另外 5 條線程執行任務。這 5 條線程會從一個線程安全的隊列中取出須要解析的頁面連接,而且當它們解析完畢以後會獲取到下一個頁面的連接並插入到列表中。另外,咱們創建了一個對象 Repository 用來描述一個項目。因而代碼以下,
// 線程池
private static ExecutorService executorService = Executors.newFixedThreadPool(6);
// 頁面連接
private static BlockingQueue<String> pages = new ArrayBlockingQueue<>(10);
// 解析歷史信息
private static List<String> histories = Collections.synchronizedList(new LinkedList<>());
// 解析出的項目記錄
private static List<Repository> repositories = Collections.synchronizedList(new LinkedList<>());
// 布爾類型,用來標記是否解析完最後一頁
private static AtomicBoolean lastPageParsed = new AtomicBoolean(false);
public static void main(String...args) {
// 啓動監控線程
executorService.execute(new Watcher());
// 啓動解析線程
executorService.execute(new Parser("https://github.com/" + USER_NAME + "?tab=stars"));
}
private static class Parser implements Runnable {
private final String page;
private Parser(String page) {
this.page = page;
}
@Override
public void run() {
try {
// 停頓必定時間
Thread.sleep(DELAY_MILLIS);
// 開始解析
doParse();
} catch (InterruptedException | IOException | ParseException e) {
e.printStackTrace();
}
}
private void doParse() throws IOException, InterruptedException, ParseException {
System.out.println("Start to parse " + page);
Document doc = Jsoup.connect(page).get();
// 解析網頁信息
pages.remove(page);
}
}
private static class Watcher implements Runnable {
@Override
public void run() {
try {
boolean shouldStop = false;
while (!shouldStop) {
// 停頓必定時間,不用檢查太頻繁
Thread.sleep(WATCH_SPAN_MILLIS);
if (lastPageParsed.get() && pages.isEmpty()) {
// 最終完成,寫入數據
shouldStop = true;
executorService.shutdown();
System.out.println("Total repositories " + repositories.size());
System.out.println("Begin to write to database......");
H2DBWriter.write(repositories);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
// 數據庫記錄
public static class Repository { /* ..... */ }
複製代碼
而後是數據庫讀寫部分。咱們須要先在數據庫中執行數據庫記錄的 SQL,
CREATE TABLE REPOSITORY
(id INTEGER not NULL AUTO_INCREMENT,
userName VARCHAR(255),
repoName VARCHAR(255),
ownerName VARCHAR(255),
repoLink VARCHAR(255),
description VARCHAR(1000),
language VARCHAR(255),
starNum INTEGER,
date TIMESTAMP,
PRIMARY KEY ( id ))
複製代碼
而後,是寫入數據部分。這裏就是將列表中的記錄一個個地構建成一條 SQL 並將其插入到數據庫中,
public static void write(List<GithubStarExample.Repository> repositories) {
Connection conn = null;
Statement stmt = null;
try {
// 設置數據庫驅動
Class.forName(H2DBConfig.JDBC_DRIVER);
// 獲取數據庫鏈接,須要驅動和帳號密碼等信息
conn = DriverManager.getConnection(H2DBConfig.DB_URL, H2DBConfig.USER, H2DBConfig.PASS);
stmt = conn.createStatement();
// 遍歷進行寫入
for (GithubStarExample.Repository repository : repositories) {
PreparedStatement preparedStatement = conn.prepareStatement("INSERT INTO REPOSITORY (userName, repoName, ownerName, repoLink, description, language, starNum, date) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
preparedStatement.setString(1, repository.userName);
preparedStatement.setString(2, repository.repoName);
preparedStatement.setString(3, repository.ownerName);
preparedStatement.setString(4, repository.repoLink);
preparedStatement.setString(5, repository.description);
preparedStatement.setString(6, repository.language);
preparedStatement.setInt(7, repository.starNum);
preparedStatement.setString(8, new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(repository.date.getTime()));
preparedStatement.execute();
}
System.out.println("Database writing completed!");
// 關閉數據庫鏈接
stmt.close();
conn.close();
} catch(Exception e) {
e.printStackTrace();
} finally {
try{
if(stmt != null) stmt.close();
} catch(SQLException se2) {
se2.printStackTrace();
}
try {
if(conn != null) conn.close();
} catch(SQLException se) {
se.printStackTrace();
}
}
}
複製代碼
至於數據庫讀取部分,咱們不詳細敘述了。你能夠經過查看源碼來自行了解。
最後是測試階段,注意須要先關閉以前打開的頁面客戶端,而後再進行測試。
上面咱們只是簡單介紹了爬蟲的一些基本的內容,固然,以上各個部分均可能存在一些欠缺。好比數據庫讀寫性能比較低,以及執行 HTML 解析過程當中線程池的利用率問題。畢竟上述只是一個小的示例,若是讀者對讀寫和其餘性能有更高的要求,能夠按照以前說的,加入數據庫鏈接池,並優化線程池的效率等。
上面咱們使用的是 Jsoup 來抓取頁面信息,它能夠解決一部分問題。對於由前端渲染的網頁它就機關用盡了。所謂前端渲染就是指,頁面加載出來以後或者當用戶執行了某些操做以後,好比頁面滾動等,再進行數據加載。對應地,後端渲染主要是指服務端把頁面渲染完畢以後再返回給客戶端。
咱們能夠以抓取 LeetCode 的題目信息爲例。
以前,爲了隨時隨地查看 LeetCode 上面的題目,我但願將它們從頁面上面拉取下來,保存到本地數據庫,以便在移動端和其餘設備上面離線查看。我分析了它的題目列表頁面,
如圖所示,按照上面的分析,當咱們使用 Jsoup 獲取 class 爲 reactable-data
的元素的時候,能夠很容易地取出題目的列表元素。然而事實並不像咱們想象地那麼簡單。由於我發現,當使用 Jsoup 加載完畢的時候,整個元素列表爲空。這是由於,整個列表實際是有客戶端發起一個請求,拿到一個 json 以後,經過解析 json 把一個個列表項目構建出來的。
因此,我換了另一種解決方式,即便用 Chrome 的 Network 監聽,獲取該頁面訪問服務器的請求連接。(這種由前端渲染的頁面能夠先考慮使用這種方式,它更簡單,你甚至不須要使用 Jsoup 解析 HTML.)
如圖,這樣咱們就輕鬆拿到了獲取全部題目的請求的連接。吐槽一下,這個請求居然返回了所有 900 多道題目,整個 json 的字符長度長達 26 萬……無論怎樣,咱們拿到了全部題目的請求的連接。
但只有連接仍是不夠的,咱們還但願獲取全部題目的描述,最好把整個標籤所有抓下來。
此時,咱們又遇到了上述的問題,即 LeetCode 的題目的頁面也是又前端渲染的。不過我仍是能找到它的請求地址,
當我拿到了這個請求以後到 Postman 裏面調試了一下,發現這個連接使用了 Referer 字段用來防盜鏈。沒辦法,直接從訪問該請求獲取描述信息遇到了障礙。此時,我想到了幾種解決辦法。
第一,按照不少人說的,使用 HtmlUnit,然而我到 github 看了一下這個項目,只有 83 個 Star,嘗試了一下還出現了 SOF 的錯誤,因此,只能放棄,
第二,使用 PhantomJs. 這應該算是一種終結的解決辦法了。PhantomJs 用來模擬 JS 調用,配合 Selenium,能夠用來模擬瀏覽器請求。這樣咱們能夠等前端渲染完成以後獲取到完整的 HTML.
按照上面的描述,咱們須要在項目中另外引入幾個依賴。首先對於從服務器返回的 Json 的問題,咱們使用 OkHttp + Retrofit 來自動映射。所以,咱們須要引入以下的依賴,
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>3.11.0</version>
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>retrofit</artifactId>
<version>2.4.0</version>
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>converter-gson</artifactId>
<version>2.4.0</version>
</dependency>
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>adapter-rxjava2</artifactId>
<version>2.4.0</version>
</dependency>
複製代碼
這裏引入了 OkHttp 進行網絡訪問,引入了 Retorfit 以及對應的請求轉換器和適配器,用來將請求轉換成 RxJava2 的形式。
另外,咱們還須要引入 PhantomJs 和 Selenium,
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>3.9.1</version>
</dependency>
<dependency>
<groupId>com.codeborne</groupId>
<artifactId>phantomjsdriver</artifactId>
<version>1.4.4</version>
</dependency>
複製代碼
對於這兩個依賴,我使用的是 2018 年 2-3 月發佈的版本。早期的版本可能會存在一些 Bug,另外就是注意它們之間的版本的搭配問題。
這樣,咱們就完成了依賴的引用。而後,咱們先寫請求 Json 部分的代碼,
// 服務端接口封裝
public interface ProblemService {
@GET("problems/all/")
Observable<AllProblems> getAll();
}
// 獲取所有題目
private static void getAllProblems() {
ProblemService service = new Retrofit.Builder()
.baseUrl(BASE_URL)
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ProblemService.class);
Disposable d = service.getAll()
.subscribe(System.out::println);
}
複製代碼
這樣咱們就拿到了整個題目列表。而後,咱們須要對題目的內容進行解析。
對於每一個題目的內容的連接的構成是,https://leetcode.com/problems/題目對應的question__title_slug/
。咱們可使用上述請求的結果直接構建出題目的連接。
private static void testPhantomJs() throws IOException {
System.setProperty("phantomjs.binary.path", "D://Program Files/phantomjs-2.1.1/bin/phantomjs.exe"); // 這裏寫你安裝的phantomJs文件路徑
WebDriver webDriver = new PhantomJSDriver();
((PhantomJSDriver) webDriver).setErrorHandler(new ErrorHandler());
webDriver.manage().timeouts().implicitlyWait(30, TimeUnit.SECONDS);
webDriver.get(PROBLEM_CONTENT_TEST_URL);
System.out.println(webDriver.getPageSource());
}
複製代碼
上面就是 PhantomJs + Selenium 請求執行的過程,如以上輸出正確結果,則咱們就能夠直接對獲得的 HTML 的內容進行解析了。解析的時候不論使用 Jsoup 仍是使用 Selenium 提供的一些方法皆可。
固然,使用 PhantomJs 以前須要先進行安裝才行。直接經過 phantomjs.org/download.ht… 進入下載頁面下載並安裝便可。
這裏咱們介紹了使用 PhantomJs + Selenium 抓取由前端渲染的頁面的步驟。這種類型的頁面主要就兩種方式吧,一個是嘗試直接拿到前端請求的連接,一個是直接使用 PhantomJs + Selenium 模擬瀏覽器拿到 HTML 以後再解析。
以上就是咱們經常使用的兩種抓取頁面信息的方式。掌握了這些技能以後你就可使用它來抓取網上的信息,作出一些好玩的東西了。
若有疑問,歡迎評論區交流 :)
源碼地址:Java-Advanced/Jsoup