天天看點

java網絡爬蟲開發筆記(2)

問題

上次講到jsoup對于response header裡面的Location項有誤讀,後來又發現這種現象的更根本的原因是jsoup裡面經過了兩次的urlEncode過程,于是最初的連結

http://example.com/你好
           

第一次被轉換成

http://example.com/%e4%bd%a0%e5%a5%bd
           

第二次被轉換成

http://example.com/%25e4%25bd%25a0%25e5%25a5%25bd
           

再去通路就直接404了,這樣不行。

解決方案

這個問題的解決方案其實說起來特别傻:抛棄jsoup的http部分隻用它來解析html,與http協定打交道的活兒交給另一個注明的java網絡庫來做:httpclient。(主要是因為httpclient我不是第一次打交道了,以前也用過,其他還可以用的諸如jetty,netty之類的當然也可以。我的結論是jsoup的http子產品有問題,不夠成熟,因為我都是直接 照着最基礎的教程寫出來的,如果是我使用不當的原因的話,歡迎指出。)

于是原先的代碼是(看起來很簡單,但是麻煩重重):

public static Document parse(String url) throws IOException {
    return Jsoup.connect(url).get();
}
           

現在是(看起來比較複雜,但是穩定沒bug):

public static Document parse(String url) throws IOException {
    CloseableHttpClient client = HttpClients.createDefault();
    HttpGet get = new HttpGet(url);
    HttpResponse response = client.execute(get);
    return Jsoup.parse(response.getEntity().getContent(), "UTF-8", url);
}
           

跑了一遍沒啥問題,原先解析不出來要報錯的連結都解出來了,很爽。

如果我用jsoup的時候出毛病真的是因為我用的姿勢不對,還請各位斧正。

問題

在爬國内網站的時候速度确實顯著地比國外網站要快很多,然而還是不夠盡如人意:爬完1000左右的頁面仍然要10分鐘左右,這個速度離所謂的“快”還差得遠。profiler跑一下,主要的時間都花在網絡通路上,而且還是阻塞的——一個頁面加載完整後才能找到其中的所有連結,才能将他們壓入到隊列裡面去。

對此,一個很容易想到的解決方案就是:線程池。創造多個線程來消費“待爬頁面”隊列,提高速度,然而這個解答并不如它看起來的那麼顯然:

常見的線程池,包括java.util.concurrent包裡面的諸如Executor之類,都基于生産者-消費者分離的模型。也就是說,生産者隻管生産,消費者隻管消費,兩者互不幹擾,是以才會有Executor裡面的這樣一段注釋:

Executor不會自動停止,需要調用shutdown()方法指令它在正在執行的所有任務執行完成之後自動停止。

能夠這樣說,基于一個非常簡單的事實:當生産者停止時,我就可以毫無顧慮地保證不會産生新的需求,進而指令線程池停止。

可惜的是,我們現在遇到的情況卻不是這麼簡單。

加載+解析完一個網頁之後,很有可能根據裡面的

<a>

來找到新的待解析的頁面。也就是說,消費者本身也是生産者。如果僅僅在隊列為空之後就調用Executor的

shutdown()

方法的話,就會導緻這些正在執行的任務所創造的需求被忽略了。

最極端的情況下,在隊列的第一個(也就是最初的一個)連結被取出之後,因為

queue.isEmpty()

true

,循環立刻結束,真正爬到的頁面隻有這一個,這顯然不是我們想要的。

那麼問題就是,如何確定所有的任務都正确地結束了呢?也就是說,目前隊列為空,并且線程池裡面所有的線程都執行完畢,不會創造新的需求?

解決方案

苦心人,天不負,多番嘗試之後,我在stack overflow上找到了這樣一個回答:awaitTermination of all recursively created tasks

java網絡爬蟲開發筆記(2)

照裡面說的寫了

InverseSemaphore.java

,然後再上

ExecutorService

,10個線程一起開動,那叫一個爽啊!一分半就扒了1000個不同的頁面(當然還有爆滿的mysql dashboard)。

也差不多是時候貼一下代碼了:

package com.std4453.crawerlab.main;

import com.std4453.crawlerlab.db.DB;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class CrawlerTest {
    private DB db;
    private ExecutorService executor;
    private InverseSemaphore semaphore;

    public CrawlerTest() {
        this.db = new DB();
        this.semaphore = new InverseSemaphore();
    }

    // ====== CRAWLING BEHAVIOR ======

    private void processPage(String url) {
        try {
            // check whether the given url is in the database
            String sql = "SELECT * FROM Record WHERE URL = '" + url + "';";
            ResultSet result = this.db.runSQL(sql);

            if (!result.next()) {
                // store url into database
                sql = "INSERT INTO record (URL) VALUES (?);";
                PreparedStatement statement = this.db.connection.prepareStatement(sql,
                        Statement.RETURN_GENERATED_KEYS);
                statement.setString(, url);
                statement.execute();

                // fetch page
                Document doc;
                try {
                    doc = this.parse(url);
                    if (this.matches(doc))
                        this.foundUrl(url);
                } catch (IOException e) {
                    System.err.println("Unable to fetch url: " + url);
                    e.printStackTrace();
                    return;
                }

                // crawl
                Elements links = doc.select("a[href]");
                for (Element link : links) {
                    String href = link.attr("abs:href");
                    if (this.inRange(href))
                        this.submit(href);
                }
            }
        } catch (SQLException e) {
            e.printStackTrace();
        } finally {
            // task completed
            this.semaphore.taskCompleted();
        }
    }

    private Document parse(String url) throws IOException {
        CloseableHttpClient client = HttpClients.createDefault();
        HttpGet get = new HttpGet(url);
        HttpResponse response = client.execute(get);
        return Jsoup.parse(response.getEntity().getContent(), "UTF-8", url);
    }

    private void submit(final String url) {
        this.semaphore.beforeSubmit();
        this.executor.submit(() -> CrawlerTest.this.processPage(url));
    }

    public void run() throws IOException, InterruptedException {
        this.beforeRun();

        // before
        try {
            db.runSQL2("TRUNCATE Record;");
        } catch (SQLException e) {
            e.printStackTrace();
        }
        this.executor = Executors.newFixedThreadPool();

        // crawl
        this.submit(this.startPage());

        // after
        this.semaphore.awaitCompletion();
        this.executor.shutdown();
        this.executor.awaitTermination(, TimeUnit.MINUTES);

        this.afterRun();
    }

    // ====== CRAWLING LOGIC ======

    private String startPage() {
        return "http://www.zhangxinxu.com";
    }

    private boolean inRange(String url) {
        return url.contains("zhangxinxu.com");
    }

    private boolean matches(Document unused) {
        return true;
    }

    private PrintWriter out;

    private void beforeRun() throws IOException {
        this.out = new PrintWriter(new FileOutputStream(new File("output.txt")));
    }

    private void afterRun() {
        this.out.close();
    }

    private void foundUrl(String line) {
        this.out.println(line);
    }

    // ====== PROGRAM ENTRANCE ======

    public static void main(String[] args) throws Exception {
        CrawlerTest crawlerTest = new CrawlerTest();
        crawlerTest.run();
    }
}
           

其中DB和InverseSemaphore兩個類就是兩篇文章中一模一樣的,一點都沒改(除了包名),是以就不貼了。整個程式精煉小巧,150行都不到,卻能從根部扒出整一個站點的所有頁面,可謂驚人。

小結論

java作為如今web的主要語言之一,其上下遊部件的完整性自然是不容小觑的。任何有一定java基礎的人,都可以像我這樣,稍稍研究一陣,就能寫出一個實際能跑的網絡爬蟲出來。

本系列《java網絡爬蟲開發筆記》到這裡當然也遠遠稱不上完結,正如我在本部落格的第一篇文章裡面說的一般,部落格的存在就是為了總結經驗教訓,而我在這樣一個起步階段,可供總結的經驗教訓還多得很,自然不敢妄談完結。明天的本系列第三篇将會介紹爬蟲進一步的優化和調整的步驟,也願有意學習這方面的朋友借鑒我的學習道路,共同提高自身。

(代碼打打怎麼都一點多了。。睡覺睡覺。。明天要起不來了。。)