是時候 Get 新技能了:使用 Java 爬取網頁資訊

WngShhng發表於2019-01-27

如果你想利用自己的技術做出一點有意思的產品來,那麼爬蟲、演算法和 AI 等技術可能是一個不錯的突破口。今天,我們就來介紹下使用 Java 爬取頁面資訊的幾種思路。

說起爬蟲,自從 Python 興起之後,人們可能更多地使用 Python 進行爬蟲. 畢竟,Python 有許多封裝好的庫。但對於 Javaer,如果你覺得學習 Python 成本比較高的話,使用 Java 也是一個不錯的選擇,尤其是當你希望在客戶端進行爬蟲的時候。

在這篇文中我們會以幾個頁面爬取的小例子來介紹使用 Java 進行爬蟲的幾種常用的手段。

1、使用 Jsoup 整理你的 Github

也許有許多人會像我一樣,喜歡 Star 各種有趣的專案,希望某天用到的時候再看。但當你 Star 的專案太多的時候,想要再尋找之前的某個專案就會比較困難。因為尋找專案的時候必須在自己的 Star 列表中一頁一頁地去翻(國內訪問 Github 的速度還比較慢)。你也可以用筆記整理下自己 Star 的專案,但這種重複性的工作,超過三次,就應該考慮用程式來解決了。此外,我們希望尋找自己 Star 過的專案的時候能夠像檢索資料庫記錄一樣一個 SQL 搞定。所以,我們可以直接爬取 Star 列表並儲存到本地資料庫,然後我們就可以對這些資料為所欲為了不是?

下面我們開始進行頁面資訊的抓取。

1.1 頁面分析

抓取頁面資訊之前我們首先要做的是分析頁面的構成。我們可以使用 Chrome 來幫助我們解決這個問題。方式很簡單,開啟 Chrome,登入自己的 Github,然後點選頁面的 Star 的 Tab 即可。然後,我們在自己的 Star 列表的一個條目上面進行 "檢查" 即可,

Githu 的 Star 的列表

如圖所示,頁面的一個 <div> 標籤就對應了列表中的一個元素。我們可以使用 Jsoup 直接抓取到這個標籤,然後對標籤的子元素資訊進行提取。這樣就可以將整個列表中的資訊全部檢索出來。

頁面分析的另一個問題是頁面的自動切換,即,因為 Star 列表是分頁的,一個頁面資訊載入完畢之後我們需要讓程式自動跳轉到下一頁進行載入。對於 Github,這個要求是很容易滿足的。因為 Github 的 Star 頁面完全由服務端渲染完畢之後返回的,所以我們可以在頁面中直接找到下一頁的連結。

Github 的 Star 的 Next 按鈕

如圖所示,我們直接在頁面的 Next 按鈕上面進行 "檢查" 就可以看到,Next 按鈕是一個 <a> 標籤,這裡直接包含了下一個頁面的連結。

1.2 頁面分析工具庫

頁面資訊的分析,我們使用 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/.

1.3 配置資料庫

然後,我們需要考慮的是資料的儲存的問題。如果爬蟲的資料量比較大、對資料庫效能要求比較高的話,你可以使用 MySQL 和資料庫連線池來提升讀寫效能。這裡我們使用一種簡單、輕量的資料庫 H2. H2 是一個小型嵌入式資料庫,它開源、純java實現,是關聯式資料庫,小巧且方便,非常適合我們的應用場景。

參考下面的步驟進行安裝:在windows上安裝H2資料庫

然後按照說明的方式開啟即可。如果開啟的時候發生了錯誤,需要檢查下是否是埠被佔用的問題。可以使用 H2 Console (Command line) 來開啟。如果確實是因為埠占用的問題,參考下面的步驟結束佔用埠的程式即可,如何檢視某個埠被誰佔用

1.4 編碼

這裡我們使用 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();
            }
        }
    }
複製程式碼

至於資料庫讀取部分,我們不詳細敘述了。你可以通過檢視原始碼來自行了解。

最後是測試階段,注意需要先關閉之前開啟的頁面客戶端,然後再進行測試。

1.5 小結

上面我們只是簡單介紹了爬蟲的一些基本的內容,當然,以上各個部分都可能存在一些欠缺。比如資料庫讀寫效能比較低,以及執行 HTML 解析過程中執行緒池的利用率問題。畢竟上述只是一個小的示例,如果讀者對讀寫和其他效能有更高的要求,可以按照之前說的,加入資料庫連線池,並優化執行緒池的效率等。

2、使用 PhantomJs + Selenium 抓取 LeetCode 資訊

2.1 分析

上面我們使用的是 Jsoup 來抓取頁面資訊,它可以解決一部分問題。對於由前端渲染的網頁它就無計可施了。所謂前端渲染就是指,頁面載入出來之後或者當使用者執行了某些操作之後,比如頁面滾動等,再進行資料載入。對應地,後端渲染主要是指服務端把頁面渲染完畢之後再返回給客戶端。

我們可以以抓取 LeetCode 的題目資訊為例。

之前,為了隨時隨地檢視 LeetCode 上面的題目,我希望將它們從頁面上面拉取下來,儲存到本地資料庫,以便在移動端和其他裝置上面離線檢視。我分析了它的題目列表頁面,

LeetCode 題目檢查

如圖所示,按照上面的分析,當我們使用 Jsoup 獲取 class 為 reactable-data 的元素的時候,可以很容易地取出題目的列表元素。然而事實並不像我們想象地那麼簡單。因為我發現,當使用 Jsoup 載入完畢的時候,整個元素列表為空。這是因為,整個列表實際是有客戶端發起一個請求,拿到一個 json 之後,通過解析 json 把一個個列表專案構建出來的。

所以,我換了另外一種解決方式,即使用 Chrome 的 Network 監聽,獲取該頁面訪問伺服器的請求連結。(這種由前端渲染的頁面可以先考慮使用這種方式,它更簡單,你甚至不需要使用 Jsoup 解析 HTML.)

LeetCode 網路監聽

如圖,這樣我們就輕鬆拿到了獲取所有題目的請求的連結。吐槽一下,這個請求竟然返回了全部 900 多道題目,整個 json 的字元長度長達 26 萬……不管怎樣,我們拿到了所有題目的請求的連結。

但只有連結還是不夠的,我們還希望獲取所有題目的描述,最好把整個標籤全部抓下來。

此時,我們又遇到了上述的問題,即 LeetCode 的題目的頁面也是又前端渲染的。不過我還是能找到它的請求地址,

LeetCode 題目詳情

當我拿到了這個請求之後到 Postman 裡面除錯了一下,發現這個連結使用了 Referer 欄位用來防盜鏈。沒辦法,直接從訪問該請求獲取描述資訊遇到了障礙。此時,我想到了幾種解決辦法。

第一,按照很多人說的,使用 HtmlUnit,然而我到 github 看了一下這個專案,只有 83 個 Star,嘗試了一下還出現了 SOF 的錯誤,所以,只能放棄,

第二,使用 PhantomJs. 這應該算是一種終結的解決辦法了。PhantomJs 用來模擬 JS 呼叫,配合 Selenium,可以用來模擬瀏覽器請求。這樣我們可以等前端渲染完成之後獲取到完整的 HTML.

2.2 編碼

按照上面的描述,我們需要在專案中另外引入幾個依賴。首先對於從伺服器返回的 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… 進入下載頁面下載並安裝即可。

2.3 小結

這裡我們介紹了使用 PhantomJs + Selenium 抓取由前端渲染的頁面的步驟。這種型別的頁面主要就兩種方式吧,一個是嘗試直接拿到前端請求的連結,一個是直接使用 PhantomJs + Selenium 模擬瀏覽器拿到 HTML 之後再解析。

總結

以上就是我們常用的兩種抓取頁面資訊的方式。掌握了這些技能之後你就可以使用它來抓取網上的資訊,做出一些好玩的東西了。

如有疑問,歡迎評論區交流 :)

原始碼地址:Java-Advanced/Jsoup

相關文章