近期被《我不是药神》这部国产神剧刷屏了,为了分析观众对于这部电影的真实感受,我爬取了豆瓣电影影评数据。当然本文仅讲爬虫部分(暂不涉及分析部分),属于比较基础的爬虫实现,分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感觉略重)。