美文网首页
使用爬虫爬取豆瓣电影影评数据Java版

使用爬虫爬取豆瓣电影影评数据Java版

作者: ed72fd6aaa3c | 来源:发表于2018-07-12 19:19 被阅读643次

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

    相关文章

      网友评论

          本文标题:使用爬虫爬取豆瓣电影影评数据Java版

          本文链接:https://www.haomeiwen.com/subject/yvgrpftx.html