一个简单的爬虫,用于抓取csdn上的每周干货推荐。

    使用到的相关技术:SpringBoot、Redis、Jsoup、JQuery、Bootstrap等。

示例地址:

    http://tinyspider.anxpp.com/

效果图:

 

1、写在前面

    准备熟悉下Spring Boot + Redis的使用,所以就想到爬点东西出来,于是用上了号称Java版JQuery的Jsoup,实现的功能是获取每周的CSDN推荐文章,并缓存到Redis中(当然也可以持久化到数据库,相关配置已添加,只是没有实现),网页解析部分已抽象为接口,根据要抓取的不同网页,可以自定义对应的实现,也就是可以爬取任何网页了。

    解析网页的方法返回的数据为List<Map>,再定义对应的实体,可以直接反射为实体(已实现),具体见后文的代码介绍。

    下面介绍具体实现的步骤。

2、搭建Spring Boot并集成Redis

    Spring Boot工程的搭建不用多说了,不管是Eclipse还是Idea,Spring都提供了懒人工具,可根据要使用的组件一键生成项目。

    下面是Redis,首先是引入依赖:

      <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

    然后添加配置文件:

#Redis
spring.redis.database=0
spring.redis.host=****
spring.redis.password=a****
spring.redis.pool.max-active=8
spring.redis.pool.max-idle=8
spring.redis.pool.max-wait=-1
spring.redis.pool.min-idle=0
spring.redis.port=****
#spring.redis.sentinel.master= # Name of Redis server.
#spring.redis.sentinel.nodes= # Comma-separated list of host:port pairs.
spring.redis.timeout=0

    ip和端口请自行根据实际情况填写。

    然后是配置Redis,此处使用JavaConfig的方式:

package com.anxpp.tinysoft.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
/**
 * Redis缓存配置
 * Created by anxpp.com on 2017/3/11.
 */
@Configuration
@EnableCaching
public class RedisCacheConfig {
    @Value("${spring.redis.host}")
    private String host;
    @Value("${spring.redis.port}")
    private int port;
    @Value("${spring.redis.timeout}")
    private int timeout;
    @Value("${spring.redis.password}")
    private String password;
    @Bean
    public KeyGenerator csdnKeyGenerator() {
        return (target, method, params) -> {
            StringBuilder sb = new StringBuilder();
            sb.append(target.getClass().getName());
            sb.append(method.getName());
            for (Object obj : params) {
                sb.append(obj.toString());
            }
            return sb.toString();
        };
    }
    @Bean
    public JedisConnectionFactory redisConnectionFactory() {
        JedisConnectionFactory factory = new JedisConnectionFactory();
        factory.setHostName(host);
        factory.setPort(port);
        factory.setPassword(password);
        factory.setTimeout(timeout); //设置连接超时时间
        return factory;
    }
    @Bean
    public CacheManager cacheManager(RedisTemplate redisTemplate) {
        RedisCacheManager cacheManager = new RedisCacheManager(redisTemplate);
        // Number of seconds before expiration. Defaults to unlimited (0)
        cacheManager.setDefaultExpiration(10); //设置key-value超时时间
        return cacheManager;
    }
    @Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
        StringRedisTemplate template = new StringRedisTemplate(factory);
        setSerializer(template); //设置序列化工具,这样ReportBean不需要实现Serializable接口
        template.afterPropertiesSet();
        return template;
    }
    private void setSerializer(StringRedisTemplate template) {
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        template.setValueSerializer(jackson2JsonRedisSerializer);
    }
}

    如果我们有多个程序(甚至是不同语言编写的的),需要注意Redis的key和value的序列化机制,比如PHP和Java中使用Redis的默认序列化机制是不同的,如果不做配置,可能会导致两边存的数据互相取不出来。

    这样一来,就配置好了,后面直接使用就好。

3、网页解析抽象和csdnweekly实现

    首先定义网页解析接口:

package com.anxpp.tinysoft.Utils.analyzer;
import org.jsoup.nodes.Document;
import java.util.List;
import java.util.Map;
/**
 * 解析html文档抽象
 * Created by anxpp.com on 2017/3/11.
 */
public interface DocumentAnalyzer {
    /**
     * 根据html文档对象获取List<Map>
     * @param document html文档对象
     * @return 结果
     */
    List<Map<String,Object>> forListMap(Document document);
}

    针对csdn的每周干货推荐,编写具体实现:

package com.anxpp.tinysoft.Utils.analyzer.impl;
import com.anxpp.tinysoft.Utils.analyzer.DocumentAnalyzer;
import org.jsoup.nodes.Document;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
 * 解析CSDN每周知识干货html文档具体实现
 * Created by anxpp.com on 2017/3/11.
 */
@Component
public class CsdnWeeklyDocumentAnalyzer implements DocumentAnalyzer {
    /**
     * 根据html文档对象获取List<Map>
     * @param document html文档对象
     * @return 结果
     */
    @Override
    public List<Map<String,Object>> forListMap(Document document) {
        List<Map<String,Object>> results = new ArrayList<>();
        if(ObjectUtils.isEmpty(document))
            return results;
        document.body().getElementsByClass("pclist").get(0).children().forEach(ele -> {
            Map<String,Object> result = new HashMap<>();
            result.put("type",ele.getElementsByTag("span").get(0).getElementsByTag("a").get(0).attr("href"));
            result.put("img",ele.getElementsByTag("span").get(0).getElementsByTag("a").get(0).getElementsByTag("img").get(0).attr("src"));
            result.put("url",ele.getElementsByTag("span").get(1).getElementsByTag("a").get(0).attr("href"));
            result.put("name",ele.getElementsByTag("span").get(1).getElementsByTag("a").get(0).text());
            result.put("views",Integer.valueOf(ele.getElementsByTag("span").get(1).getElementsByTag("span").get(0).getElementsByTag("em").get(0).text().replaceAll("\\D+","")));
            result.put("collections",Integer.valueOf(ele.getElementsByTag("span").get(1).getElementsByTag("span").get(1).getElementsByTag("em").get(0).text().replaceAll("\\D+","")));
            results.add(result);
        });
        return results;
    }
}

    当然,如果需要解析其他网页,实现DocumentAnalyzer接口,完成对应的解析方式也是完全可以的。

    然后,我们需要一个工具将Map转换为实体对象:

package com.anxpp.tinysoft.Utils;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.Set;
/**
 * 简单工具集合
 * Created by anxpp.com on 2017/3/11.
 */
class TinyUtil {
    /**
     * map转对象
     *
     * @param map  map
     * @param type 类型
     * @param <T>  泛型
     * @return 对象
     * @throws Exception 反射异常
     */
    static <T> T mapToBean(Map<String, Object> map, Class<T> type) throws Exception {
        if (map == null) {
            return null;
        }
        Set<Map.Entry<String, Object>> sets = map.entrySet();
        T entity = type.newInstance();
        Method[] methods = type.getDeclaredMethods();
        for (Map.Entry<String, Object> entry : sets) {
            String str = entry.getKey();
            String setMethod = "set" + str.substring(0, 1).toUpperCase() + str.substring(1);
            for (Method method : methods) {
                if (method.getName().equals(setMethod)) {
                    method.invoke(entity, entry.getValue());
                }
            }
        }
        return entity;
    }
}

    下面就是具体的数据获取逻辑了。

4、提供API以及数据获取逻辑

    首先我们需要定义一个实体:

package com.anxpp.tinysoft.core.entity;
import javax.persistence.*;
import java.util.Date;
/**
 * 文章信息
 * Created by anxpp.com on 2017/3/11.
 */
@Entity
@Table(name = "t_csdn_weekly_article")
public class Article extends BaseEntity{
    /**
     * 文章名称
     */
    private String name;
    /**
     * 文章名称
     */
    private String url;
    /**
     * 属于哪一期
     */
    private Integer stage;
    /**
     * 浏览量
     */
    private Integer views;
    /**
     * 收藏数
     */
    private Integer collections;
    /**
     * 所属知识库类别
     */
    private String type;
    /**
     * 类别图片地址
     */
    private String img;
    /**
     * 更新时间
     */
    @Column(name = "update_at", nullable = false)
    @Temporal(TemporalType.TIMESTAMP)
    private Date updateAt;
    //省略get set 方法
}

    如果要持久化数据到数据库,也可以添加Repo层,使用Spring Data JPA也是超级方便的,博客中已提供相关文章参考,此处直接使用Redis,跳过此层。

    Service接口定义:

package com.anxpp.tinysoft.core.service;
import com.anxpp.tinysoft.core.entity.Article;
import java.util.List;
/**
 * 文章数据service
 * Created by anxpp.com on 2017/3/11.
 */
public interface ArticleService {
    /**
     * 根据期号获取文章列表
     * @param stage 期号
     * @return 文章列表
     */
    List<Article> forWeekly(Integer stage) throws Exception;
}

    Service实现:

package com.anxpp.tinysoft.core.service.impl;
import com.anxpp.tinysoft.Utils.ArticleSpider;
import com.anxpp.tinysoft.Utils.analyzer.impl.CsdnWeeklyDocumentAnalyzer;
import com.anxpp.tinysoft.core.entity.Article;
import com.anxpp.tinysoft.core.service.ArticleService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;
/**
 * 文章service实现
 * Created by anxpp.com on 2017/3/11.
 */
@Service
public class ArticleServiceImpl implements ArticleService {
    @Value("${csdn.weekly.preurl}")
    private String preUrl;
    @Resource
    private CsdnWeeklyDocumentAnalyzer csdnWeeklyDocumentAnalyzer;
    /**
     * 根据期号获取文章列表
     *
     * @param stage 期号
     * @return 文章列表
     */
    @Override
    @Cacheable(value = "reportcache", keyGenerator = "csdnKeyGenerator")
    public List<Article> forWeekly(Integer stage) throws Exception {
        List<Article> articleList = ArticleSpider.forEntityList(preUrl + stage, csdnWeeklyDocumentAnalyzer, Article.class);
        articleList.forEach(article -> article.setStage(stage));
        return articleList;
    }
}

    csdn.weekly.preurl为配置文件中配置的url前缀,后面会放出完整的配置文件。

    最后就是提供对外的接口,本文只添加了一个,也可以按需添加其他API:

package com.anxpp.tinysoft.controller;
import com.anxpp.tinysoft.core.entity.Article;
import com.anxpp.tinysoft.core.service.ArticleService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.annotation.Resource;
import java.util.List;
/**
 * 默认页面
 * Created by anxpp.com on 2017/3/11.
 */
@Controller
@RequestMapping("/article")
public class ArticleController {
    @Resource
    private ArticleService articleService;
    @ResponseBody
    @GetMapping("/get/stage/{stage}")
    public List<Article> getArticleByStage(@PathVariable("stage") Integer stage) throws Exception {
        return articleService.forWeekly(stage);
    }
}

    完整的配置文件:

server.port=****
#DataSource
spring.datasource.url=jdbc:mysql://****.***:****/****?createDatabaseIfNotExist=true
spring.datasource.username=****
spring.datasource.password=****
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
#multiple Setting
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
#Redis
spring.redis.database=0
spring.redis.host=****
spring.redis.password=a****
spring.redis.pool.max-active=8
spring.redis.pool.max-idle=8
spring.redis.pool.max-wait=-1
spring.redis.pool.min-idle=0
spring.redis.port=****
#spring.redis.sentinel.master= # Name of Redis server.
#spring.redis.sentinel.nodes= # Comma-separated list of host:port pairs.
spring.redis.timeout=0
#csdn setting
csdn.weekly.preurl=http://lib.csdn.net/weekly/

    数据库等的配置请根据实际情况配置。

    现在启动程序即可访问。

    

    源码已提交到GitHub:https://github.com/anxpp/csdnweeklySpider

    后续有时间会继续完善本程序添加更多网站内容的抓取。