天天看点

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网络爬虫开发笔记》到这里当然也远远称不上完结,正如我在本博客的第一篇文章里面说的一般,博客的存在就是为了总结经验教训,而我在这样一个起步阶段,可供总结的经验教训还多得很,自然不敢妄谈完结。明天的本系列第三篇将会介绍爬虫进一步的优化和调整的步骤,也愿有意学习这方面的朋友借鉴我的学习道路,共同提高自身。

(代码打打怎么都一点多了。。睡觉睡觉。。明天要起不来了。。)