问题
上次讲到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
照里面说的写了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(1, 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(10);
// crawl
this.submit(this.startPage());
// after
this.semaphore.awaitCompletion();
this.executor.shutdown();
this.executor.awaitTermination(10, 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网络爬虫开发笔记》到这里当然也远远称不上完结,正如我在本博客的第一篇文章里面说的一般,博客的存在就是为了总结经验教训,而我在这样一个起步阶段,可供总结的经验教训还多得很,自然不敢妄谈完结。明天的本系列第三篇将会介绍爬虫进一步的优化和调整的步骤,也愿有意学习这方面的朋友借鉴我的学习道路,共同提高自身。
(代码打打怎么都一点多了。。睡觉睡觉。。明天要起不来了。。)