近期被《我不是药神》这部国产神剧刷屏了,为了分析观众对于这部电影的真实感受,我爬取了豆瓣电影影评数据。当然本文仅讲爬虫部分(暂不涉及分析部分),属于比较基础的爬虫实现,分Java版本和Python版本,代码结构一致,仅实现语言不同。
网页结构分析
打开电影影评网页 https://movie.douban.com/subject/26752088/comments 尝试翻几页,可以看出每页的网页结构是一致的。分中间的数据列表,和底部的分页导航,其中分页导航链接用于横向抓取全部影评网页使用。单讲中间数据部分,每页为一个列表,每个列表项包含:用户头像、用户姓名、用户链接、评分(5星)、评论日期、点赞数(有用)和评论内容,本文记录用户姓名、评分、日期、点赞数和内容五个字段。
爬虫基本结构
爬虫实现为一个标准的单线程(进程)爬虫结构,由爬虫主程序、URL管理器、网页下载器、网页解析器和内容处理器几部分构成
队列管理
通过观察多页的URL发现,URL本身不发生变化,变化的仅仅只是参数,另外有部分页面URL会带 &status=P 这部分参数,有的又不带,这里统一去掉(去掉后不影响网页访问),避免相同URL因为该参数的原因被当成两个URL(这里使用的是一种简化处理的方法,请自行考虑更健壮的实现方式)
package com.zlikun.learning.douban.movie;
import java.util.*;
import java.util.stream.Collectors;
/**
* URL管理器,本工程中使用单线程,所以直接使用集合实现
*
* @author zlikun <zlikun-dev@hotmail.com>
* @date 2018/7/12 17:58
*/
public class UrlManager {
private String baseUrl;
private Queue<String> newUrls = new LinkedList<>();
private Set<String> oldUrls = new HashSet<>();
public UrlManager(String baseUrl, String rootUrl) {
this(baseUrl, Arrays.asList(rootUrl));
}
public UrlManager(String baseUrl, List<String> rootUrls) {
if (baseUrl == null || rootUrls == null || rootUrls.isEmpty()) {
return;
}
this.baseUrl = baseUrl;
// 添加待抓取URL列表
this.appendNewUrls(rootUrls);
}
/**
* 追加待抓取URLs
*
* @param urls
*/
public void appendNewUrls(List<String> urls) {
// 添加待抓取URL列表
newUrls.addAll(urls.stream()
// 过滤指定URL
.filter(url -> url.startsWith(baseUrl))
// 处理URL中的多余参数(&status=P,有的链接有,有的没有,为避免重复,统一去除,去除后并不影响)
.map(url -> url.replace("&status=P", ""))
// 过滤重复的URL
.filter(url -> !newUrls.contains(url) && !oldUrls.contains(url))
// 返回处理过后的URL列表
.collect(Collectors.toList()));
}
public boolean hasNewUrl() {
return !this.newUrls.isEmpty();
}
/**
* 取出一个新URL,这里简化处理了新旧URL状态迁移过程,取出后即认为成功处理了(实际情况下需要考虑各种失败情况和边界条件)
*
* @return
*/
public String getNewUrl() {
String url = this.newUrls.poll();
this.oldUrls.add(url);
return url;
}
}
下载器
下载器使用 OkHttp
库实现,为了简化处理登录,请求时携带了 Cookie
消息头(本人在浏览器中登录后复制过来的)
package com.zlikun.learning.douban.movie;
import lombok.extern.slf4j.Slf4j;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
/**
* HTTP下载器,下载网页和其它资源文件
*
* @author zlikun <zlikun-dev@hotmail.com>
* @date 2018/7/12 17:58
*/
@Slf4j
public class Downloader {
private OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(3000, TimeUnit.MILLISECONDS)
.build();
/**
* 下载网页
*
* @param url
* @return
*/
public String download(String url) {
// 使用Cookie消息头是为了简化登录问题(豆瓣电影评论不登录条件下获取不到全部数据)
Request request = new Request.Builder()
.url(url)
.addHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 Safari/537.36")
.addHeader("Cookie", "gr_user_id=b6c0778d-f8df-4963-b057-bd321593de1e; bid=T-M5aFmoLY0; __yadk_uid=WvMJfSHd1cjUFrFQTdN9KnkIOkR2AFZu; viewed=\"26311273_26877306_26340992_26649178_3199438_3015786_27038473_10793398_26754665\"; ll=\"108296\"; ps=y; dbcl2=\"141556470:E4oz3is9RMY\"; ap=1; _vwo_uuid_v2=E57494AA9988242B62FB576F22211CE4|e95afc3b3a6c74f0b9d9106c6546e73e; ck=OvCX; __utma=30149280.1283677058.1481968276.1531194536.1531389580.35; __utmc=30149280; __utmz=30149280.1524482884.31.29.utmcsr=baidu|utmccn=(organic)|utmcmd=organic; __utmv=30149280.14155; __utma=223695111.1691619874.1522208966.1531194536.1531389615.5; __utmc=223695111; __utmz=223695111.1524483025.2.2.utmcsr=baidu|utmccn=(organic)|utmcmd=organic; _pk_ref.100001.4cf6=%5B%22%22%2C%22%22%2C1531389615%2C%22https%3A%2F%2Fwww.baidu.com%2Flink%3Furl%3D0saOVVzXJiEvkbYGxCXZ849EweAjA2om6cIvPZ7FxE35FrmKU8CfOHm1cC9Xs0JS%26wd%3D%26eqid%3De5307bbf0006c241000000045addc33f%22%5D; _pk_id.100001.4cf6=cee42334e421195b.1522208966.5.1531389615.1531200476.; push_noty_num=0; push_doumail_num=0")
.get()
.build();
try {
Response response = client.newCall(request).execute();
if (!response.isSuccessful()) {
throw new IOException(response.code() + "," + response.message());
}
return response.body().string();
} catch (IOException e) {
log.error("下载网页[{}]失败!", url, e);
}
return null;
}
}
页面解析器
HTML 解析使用 Jsoup
库实现,负责提取网页中的数据内容和超链接
package com.zlikun.learning.douban.movie;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 网页解析器,解析网页返回链接列表和内容列表
*
* @author zlikun <zlikun-dev@hotmail.com>
* @date 2018/7/12 17:58
*/
public class PageParser<T> {
@lombok.Data
@AllArgsConstructor
@NoArgsConstructor
public static class Data<T> {
private List<String> links;
private List<T> results;
}
public Data<T> parse(String url, String html) {
Document doc = Jsoup.parse(html, url);
// 获取链接列表
List<String> links = doc.select("#paginator > a[href]").stream()
.map(a -> a.attr("abs:href"))
.collect(Collectors.toList());
// 获取数据列表
List<Map<String, String>> results = doc.select("#comments > div.comment-item")
.stream()
.map(div -> {
Map<String, String> data = new HashMap<>();
String author = div.selectFirst("h3 > span.comment-info > a").text();
String date = div.selectFirst("h3 > span.comment-info > span.comment-time").text();
Element rating = div.selectFirst("h3 > span.comment-info > span.rating");
String star = null;
if (rating != null) {
// allstar40 rating
star = rating.attr("class");
star = star.substring(7, 9);
}
String vote = div.selectFirst("h3 > span.comment-vote > span.votes").text();
String comment = div.selectFirst("div.comment > p").text();
data.put("author", author);
data.put("date", date);
if (star != null)
data.put("star", star);
data.put("vote", vote);
data.put("comment", comment);
return data;
})
.collect(Collectors.toList());
return new Data(links, results);
}
}
数据处理器
解析器中返回的数据为 List<Map<String, String>>
结构,原本应将数据写入Mongo
中,这里也简化处理(在Python版本代码中会写入Mongo),直接在控制台打印出结果
package com.zlikun.learning.douban.movie;
import java.util.List;
/**
* 数据处理器,将数据持久化到MongoDB中
*
* @author zlikun <zlikun-dev@hotmail.com>
* @date 2018/7/12 17:58
*/
public class DataProcessor<T> {
private static final int DEFAULT_PORT = 27017;
public DataProcessor(String host) {
this(host, DEFAULT_PORT);
}
public DataProcessor(String host, int port) {
// TODO 配置Mongo连接
}
public void process(List<T> results) {
if (results == null || results.isEmpty()) {
return;
}
// 暂不写入MongoDB,打印出结果即可
// {date=2018-07-04, star=50, author=忻钰坤, comment=“你敢保证你一辈子不得病?”纯粹、直接、有力!常常感叹:电影只能是电影。但每看到这样的佳作,又感慨:电影不只是电影!由衷的希望这部电影大卖!成为话题!成为榜样!成为国产电影最该有的可能。, vote=27694}
// {date=2018-07-03, star=50, author=沐子荒, comment=王传君所有不被外人理解的坚持,都在这一刻得到了完美释放。他不是关谷神奇,他是王传君。 你看,即使依旧烂片如云,只要还有哪怕极少的人坚持,中国影视也终于还是从中生出了茁壮的根。 我不是药神,治不好这世界。但能改变一点,总归是会好的。, vote=26818}
// {date=2018-06-30, star=50, author=凌睿, comment=别说这是“中国版《达拉斯买家俱乐部》”了,这是中国的真实事件改编的中国电影,是属于我们自己的电影。不知道就去百度一下“陆勇”,他卖印度抗癌药的时候《达拉斯买家俱乐部》还没上映呢。所以别提《达拉斯买家俱乐部》了,只会显得你无知。(别私信我了,我800年前就知道《达拉斯》也是真事改编), vote=18037}
// ... ...
results.stream()
.forEach(data -> {
System.out.println(data);
});
}
}
完整代码
前面的几个主要组件的代码已贴出,这里主要展示的是爬虫主程序代码
package com.zlikun.learning.douban.movie;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.atomic.AtomicLong;
/**
* 豆瓣电影影评爬虫,本爬虫是一个单线程爬虫
*
* @author zlikun <zlikun-dev@hotmail.com>
* @date 2018/7/12 17:57
*/
@Slf4j
public class Crawler {
private UrlManager manager;
private Downloader downloader;
private PageParser parser;
private DataProcessor processor;
public Crawler(UrlManager manager,
Downloader downloader,
PageParser parser,
DataProcessor processor) {
this.manager = manager;
this.downloader = downloader;
this.parser = parser;
this.processor = processor;
}
public static void main(String[] args) {
// 豆瓣影评URL部分不变,变化的只有参数部分
final String BASE_URL = "https://movie.douban.com/subject/26752088/comments";
final String ROOT_URL = BASE_URL + "?start=0&limit=20&sort=new_score&status=P";
// 构建爬虫并启动爬虫,这里仅作最小化演示,程序健壮性、扩展性等暂不考虑
Crawler crawler = new Crawler(new UrlManager(BASE_URL, ROOT_URL),
new Downloader(),
new PageParser(),
new DataProcessor("192.168.0.105"));
long urls = crawler.start();
log.info("任务执行完成,共爬取 {} 个URL", urls);
}
/**
* 启动爬虫,任务执行完成后,返回处理URL数量
*
* @return
*/
private long start() {
final AtomicLong counter = new AtomicLong();
while (manager.hasNewUrl()) {
try {
String url = manager.getNewUrl();
if (url == null) break;
counter.incrementAndGet();
String html = downloader.download(url);
PageParser.Data data = parser.parse(url, html);
if (data == null) continue;
if (data.getLinks() != null) {
manager.appendNewUrls(data.getLinks());
}
if (data.getResults() != null) {
processor.process(data.getResults());
}
} catch (Exception e) {
}
}
return counter.get();
}
}
结语
本文实现的爬虫是一个非常简陋的爬虫,并未使用并发(多线程、多进程、分布式等),也未做健壮性考虑,仅展示了爬虫的基本结构和思路。下一篇是以同样思路以Python实现的爬虫,有兴趣的读者可以对比一下两者之间的差别(个人感觉果然还是Python更适合写爬虫,Java感觉略重)。