欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

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

程序员文章站 2022-05-05 15:42:26
...

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