知乎爬虫(一)

知乎上有很多内容不错的回答,但在网上看总归对眼睛、颈椎都不利,所以想到写个脚本把内容爬下来,放到kindle中看。筛选了一下,抓取知乎的收藏夹,从抓取难度、内容组织程度等各个方面都比较合适。第一版导出txt,由于很多精彩内容图都很多,所以后续版本增加了导出pdf功能。由于只是给定url爬取内容,不涉及到读取超链接递归查询,感觉严格来说并不能叫“爬虫”,一时也不知道这应该叫什么名字,先这么称呼着吧。

语言选了最熟悉的Java,用到的技术主要包括:

  • httpclient:抓取网页内容的工具。今年更新版本到4.3,据说api给得很不错,对多线程也有了比较到位的支持。工作项目中用了2.x版本,个人代码直接上最新版。官网地址:http://hc.apache.org/index.html
  • 网页解析工具:jsoup,支持css选择器语法选择html元素。http://jsoup.org/
  • pdf生成工具:iText。常见的java pdf库,坑也不少。最诡异的一个坑是输出中文的时候必须要亚洲语言包,但这个语言包在官网上的下载地址死活都找不到,而网上能找到的大多版本比较旧,想与最新的itext版本结合的话,要手动修改包名。后来发现,核心包的下载地址指向了sourceforge网站,该网站上有extrajar的下载地址,里面有亚洲语言包,地址:http://iweb.dl.sourceforge.net/project/itext/extrajars/extrajars-2.3.zip
  • html生成pdf的辅助工具:xmlWorker,依赖于iText的工具,刚发现的时候惊为天人。官网地址:http://sourceforge.net/projects/xmlworker/。解决中文坑的参考地址:http://www.micmiu.com/opensource/expdoc/itext-xml-worker-cn/

核心代码见:http://nonesuccess.me/?p=293

抓取网页内容

httpclient支持get、post方法获取html内容。具体api使用方法,在官网的quick start上有很详尽的例子,这两者之间的区别也应该是http协议的内容。在api的形式上,简单说,get方式就是拼个字符串作url,post方式则提供了生成参数的api。

我们做企业级开发的时候很少注意get和post的区分,其实在http语义上是有get表查询、post表更新这种区分的。像知乎这种产品,感觉会有大批原教旨主义前端,所以抓取页面内容,用get就足够了。

首先新建一个HttpClient实例,再使用url构造一个HttpGet对象,最后调用httpClient实例的api执行get请求,生成response对象。

[java]

CloseableHttpClient httpclient = HttpClients.createDefault();
HttpGet httpGet = new HttpGet(url);
CloseableHttpResponse response1 = null;
try
{
response1 = httpclient.execute(httpGet);
} catch (IOException e1)
{
// TODO Auto-generated catch block
e1.printStackTrace();
}

[/java]

httpclient能够透明的处理cookie信息。 使用同一个httpclient对象执行的所有请求,共享cookie。可以采用这种机制处理需要保存session的场景,例如知乎的某些收藏夹,需要登录后才能查看。

执行http请求后,将生成一个CloseableHttpResponse对象,该对象可以获取http请求响应的相关信息。这里只需要获取返回内容,先使用getContent方法获取inputstream,再读取该inputStream,转换成String。

[java]

try
{
content = inputStreamToString(entity1.getContent(), "UTF-8");
} catch (IllegalStateException | IOException e)
{
e.printStackTrace();
}

try
{
response1.close();
} catch (IOException e)
{
e.printStackTrace();
}

[/java]

这里封装了一个inputstream转string的函数如下:

[java]

/**
* 将InputStream转换为String的工具函数 当发生IOException异常时,会将前面正确的字符串返回
*
* @param stream
* @return
*/
private static String inputStreamToString(InputStream is, String encoding)
{
int i = -1;
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try
{
while ((i = is.read()) != -1)
{
baos.write(i);
}
} catch (IOException e)
{
log.error("读取html内容的inputStream发生异常", e);
// TODO 继续向上抛出,由上层处理
}
String content = null;
try
{
content = baos.toString(encoding);
} catch (UnsupportedEncodingException e)
{
log.error("不支持的字符集" + encoding);
throw new RuntimeException(e);
}

return content;

}

[/java]

这里使用了ByteArrayOutputStream。网上流传着很多文章,使用的是每次读取一行,或者定义定长大小的byte数组循环读取的方案。前者对换行符的处理很繁琐,后者在处理到数组边界的时候会诡异的丢字符然后有一两个字节就乱码了。

读取到内容之后就可以交给解析模块解析想要的功能了。

想爬知乎的收藏夹,自然先涉及到分析url模式。这部分其实看几个网页就清楚了,比较简单。经过分析,模式为:

http://www.zhihu.com/collection/[收藏夹编号]?page=[x]

所以抓取的时候,先到网上找感兴趣的收藏夹,在地址栏中把编号粘贴出来,先读取第一页,解析总页数,再顺序抓取每一页就可以了。

 

爬知乎收藏夹

[java]

package com.nbm.spider.zhihu;

import java.io.BufferedWriter;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;

import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.itextpdf.text.BaseColor;
import com.itextpdf.text.Chunk;
import com.itextpdf.text.DocumentException;
import com.itextpdf.text.Font;
import com.itextpdf.text.FontProvider;
import com.itextpdf.text.PageSize;
import com.itextpdf.text.Paragraph;
import com.itextpdf.text.Rectangle;
import com.itextpdf.text.pdf.BaseFont;
import com.itextpdf.text.pdf.PdfWriter;
import com.itextpdf.tool.xml.XMLWorkerHelper;
import com.nbm.http.HttpGetter;
import com.nbm.spider.Getter;

public class ZhihuGetter extends Getter
{

private final static Logger log = LoggerFactory.getLogger(ZhihuGetter.class);

private final static String PAGE_TOKEN = "{}";

private final static String PATH_PREFIX = "D:\\kuaipan\\zhihu\\";

private final static int PAGE_PER_FILE = 50;

private int page;

private int currentPage;

private final static int QUESTIONS_PERPAGE = 1000;

private ZhihuFavoriteBean bean;

public ZhihuGetter(String url)
{
bean = new ZhihuFavoriteBean();

bean.setUrl(url);

File file = new File(PATH_PREFIX);

file.mkdirs();
}

public void get()
{
log.info(this.bean.getUrl().replace(PAGE_TOKEN, "1"));

Document doc = null;
try
{
doc = Jsoup.connect(this.bean.getUrl().replace(PAGE_TOKEN, "1")).get();
} catch (IOException e)
{
// TODO Auto-generated catch block
e.printStackTrace();
}

bean.setTitle(doc.select("title").text());

Elements pageElement = doc.select("div.border-pager").select("span");
if (pageElement.size() < 2)
{
page = 1;
} else
{
page = Integer.parseInt(pageElement.get(pageElement.size() – 2).text());
}

for (int i = 1; i <= page; i++)
{
String url = this.bean.getUrl().replace(PAGE_TOKEN, "" + i);
getAPage(url);
}

log.debug("共获取到问题{}条", bean.getQuestionBeans().size());

try
{
exportPdf();
} catch (Exception e)
{
// TODO Auto-generated catch block
e.printStackTrace();
}

}

private void exportPdf() throws DocumentException, IOException
{

// for (int i = 0; i < (bean.getQuestionBeans().size() – 1) / QUESTIONS_PERPAGE; i++)
// {
final BaseFont bf = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H",
BaseFont.NOT_EMBEDDED);
Font boldfont = new Font(bf, 16, Font.BOLD);
com.itextpdf.text.Document document = new com.itextpdf.text.Document();
PdfWriter writer = PdfWriter.getInstance(document, new FileOutputStream(
Paths.get(PATH_PREFIX + bean.getTitle() + ".pdf")
.toFile()));

document.open();

document.add(new Paragraph(bean.getTitle(), boldfont));
document.add(new Paragraph(bean.getUrl(), boldfont));

// int endIndex = (i + 1) * QUESTIONS_PERPAGE <= bean.getQuestionBeans().size()
// ?((i + 1) * QUESTIONS_PERPAGE)
// :bean.getQuestionBeans().size();
for (QuestionBean q : bean.getQuestionBeans())
{
document.add(new Paragraph(q.getQuestion(), boldfont));
document.add(Chunk.NEWLINE);

for (AnswerBean a : q.getAnswerBeans())
{
document.add(new Paragraph(a.getAuthor() + " "
+ a.getAnswerDate(), boldfont));
XMLWorkerHelper.getInstance().parseXHtml(
writer,
document,
new ByteArrayInputStream(Jsoup
.parse(a.getContent())
.html().getBytes()), null,
new FontProvider()
{

@Override
public boolean isRegistered(String arg0)
{
// TODO
// Auto-generated
// method
// stub
return false;
}

@Override
public Font getFont(String arg0,
String arg1,
boolean arg2,
float arg3,
int arg4,
BaseColor arg5)
{
return new Font(bf, 14);
}
});
}
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);

}

document.close();

log.info("[{]}处理完成", bean.getTitle());
// }

}

private void getAPage(String url)
{

try
{

String content = HttpGetter.INSTANCE.getContent(url);

Document doc = Jsoup.parse(content);

Elements contentDivs = doc.select("div.zm-item");

QuestionBean questionBean = new QuestionBean();
for (Iterator<Element> iter = contentDivs.iterator(); iter.hasNext();)
{
Element e = iter.next();

String question = e.select("h2.zm-item-title").text();
if (!StringUtils.isBlank(question))
{
bean.getQuestionBeans().add(questionBean);
questionBean = new QuestionBean();
questionBean.setQuestion(question);
}

AnswerBean answerBean = new AnswerBean();

answerBean.setAuthor(e.select(".zm-item-answer-author-info").text()
.replaceAll("收起", "").trim());
String answer = e.select("textarea.content.hidden").text() + "\n";

// answer =
// StringEscapeUtils.unescapeHtml4(answer).replaceAll("<br>",
// "\n");

Document answerDoc = Jsoup.parse(answer);
answerBean.setAnswerDate(answerDoc.select(
"a.answer-date-link.meta-item").text());
answerDoc.select("span.answer-date-link-wrap").remove();

for (Element element : answerDoc.select("a.member_mention"))
{
element.after(element.text());
element.remove();
}

answerBean.setContent(answerDoc.html());

questionBean.getAnswerBeans().add(answerBean);
}

} catch (IllegalStateException e1)
{
// TODO Auto-generated catch block
e1.printStackTrace();
}
}

public static void main(String[] args) throws IOException
{

String[] numbers =
{ "20547625",
"23512932",
"20021520",
"20148773",
"19650915",
"19720400",
"20189059",
"19981495",
"19591548",
"20393312",
"19610483",
"19567254",
"26460588",
"19630674",
"19960298",
"19662339"};

// Set<String> numbers1 = new HashSet<>();
//
// while(true)
// {
// Document doc = null;
// try
// {
// doc = Jsoup.connect("http://www.zhihu.com/explore").get();
// } catch (IOException e)
// {
// // TODO Auto-generated catch block
// e.printStackTrace();
// }
//
// for( Element e : doc.select("ul.list.hot-favlists a"))
// {
// log.debug(e.html());
// numbers1.add(e.attr("href").replace("/collection/", ""));
// }
//
//
// if( numbers1.size() >= 20)
// break;
// }
//
// log.debug(numbers1.toString());
//
for (final String n : numbers)
{
new Thread(new Runnable()
{

@Override
public void run()
{
new ZhihuGetter("http://www.zhihu.com/collection/" + n
+ "?page={}").get();

}
}).start();

}
}

}

[/java]