如何优雅的用Jsonrpc、RabbitMQ和Redis分布式锁实现python算法和java后端分离的的小说爬取邮箱推送系统(一)

前言

逐渐完善中........

初衷是因为个人挺喜欢看网络小说,其中阅读小说花钱是一个原因,另一个原因是小说更新每次需要看手机推送或者自己去查,很浪费时间,作为一个"懒"的程序员,就想写个系统来自动化这个事情。

不花钱阅读小说;不主动去查看小说是否更新;更新的小说,支持配置选择的小说,单本或者批量的形式,以邮箱的方式推送给我,邮箱只是推送小说的章节和概述;可以在空闲时期在web页面阅读;

系统初始设计的功能如下:

  1. 每日定时爬取小说内容,然后解析入库;
  2. 指定爬取小说的页面配置,页面维护原网站地址,页面一键爬取
  3. 当有更新时,邮箱推送用户更新小说章节和概述
  4. 入库小说的页面阅读和配置

目前实现的技术与功能:

  1. 用python实现算法端爬取信息,用Java实现信息解析和消息推送等后端管理功能;
  2. 基于JsonRpc协议实现算法端与后端的rpc调用,算法端独立运行,以rpc方式供Java后端调用;
  3. 算法端使用Python实现了爬取指定页面Xpath信息,并且解析;
  4. 后端基于Spring的@Scheduled注解实现了定时调用算法端爬取算法爬取小说信息;
  5. 基于MQ的异步消息系统,当爬取成功后,会以邮箱的形式推送小说信息;
  6. 基于Redis实现分布式锁,防止同一个小说一天推送多次;
  7. 基于spring的mail组件发送QQ邮箱;

项目github地址: 等有时间就放上来,代码都还在本地,需要规整一下

后期工作:

  1. 基于Angular实现小说配置管理和阅读界面
  2. 小说内容爬取,解析,入库存储
  3. 异步消息系统优化
  4. 分布式锁优化
  5. 定时任务优化

总体设计方案

小说系统拆分为4个独立模块:前端模块,Java后端,爬虫算法端和异步消费者服务。前端模块用来配置爬取的小说、爬取的网站、管理爬取的小说和在线阅读小说。Java后端主要实现管理功能接口,用来存储爬取的小说数据,支持定时功能,定时调用爬虫算法端,并且当爬取成功后会发送MQ给异步消费者设计。爬虫算法端主要是爬虫算法实现模块。异步消费着服务,则处理并消费异步消息。

功能实现

顺序逻辑实现图

数据库表

sql 复制代码
CREATE TABLE `tbl_ novel_info` (

  `uuid` varchar(36) NOT NULL,

  `book_title` varchar(255) NOT NULL COMMENT '小说名',

  `author` varchar(255) NOT NULL COMMENT '作者',

  `introduction` text NOT NULL COMMENT '介绍',

  `update` varchar(255) NOT NULL COMMENT '更新时间',

  `type` varchar(255) NOT NULL DEFAULT '玄幻' COMMENT '小说类型',

  `img` blob COMMENT '小说封面',

  PRIMARY KEY (`uuid`)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

爬虫算法

目标内容在网页中的位置

内容 位置-XPATH
图标 /html/body/div[3]/div[1]/div/div/div[1]/img
书名 /html/body/div[3]/div[1]/div/div/div[2]/div[1]/h1/text()
作者 /html/body/div[3]/div[1]/div/div/div[2]/div[1]/div/p[1]/text()
简介 /html/body/div[3]/div[1]/div/div/div[2]/div[2]/text()
最后更新时间 /html/body/div[3]/div[1]/div/div/div[2]/div[1]/div/p[5]/text()

爬取内容页面

python 复制代码
import requests
from lxml import etree
from PIL import Image
from io import BytesIO
import json

def crawling(main_url):

    # 请求头

    headers= {

        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0',

    }

    # 小说主页

    # main_url = "http://www.365kk.cc/31/31017/"

    # 使用get方法请求网页
    main_resp = requests.get(main_url, headers=headers)

    # 将网页内容按utf-8规范解码为文本形式

    main_text = main_resp.content.decode('utf-8')

    print(main_text)
    
    # 将文本内容创建为可解析元素

    main_html = etree.HTML(main_text)


    # 依次获取书籍的标题、作者、最近更新时间和简介

    # main_html.xpath返回的是列表,因此需要加一个[0]来表示列表中的首个元素

    img_url = main_html.xpath('/html/body/div[3]/div[1]/div/div/div[1]/img/@src')[0]

    print(img_url)

    img = requests.get(url=img_url, headers = headers).content

    bytes_stream = BytesIO(img)

    img = Image.open(bytes_stream)

    img.show()

    bookTitle = main_html.xpath('/html/body/div[3]/div[1]/div/div/div[2]/div[1]/h1/text()')[0]

    author = main_html.xpath('/html/body/div[3]/div[1]/div/div/div[2]/div[1]/div/p[1]/text()')[0]

    introduction = main_html.xpath('/html/body/div[3]/div[1]/div/div/div[2]/div[2]/text()')[0]

    update = main_html.xpath('/html/body/div[3]/div[1]/div/div/div[2]/div[1]/div/p[5]/text()')[0]

    # 输出结果以验证
    print(img)
    print(bookTitle)
    print(author)
    print(update)
    print(introduction)
    dict = {'img': img_url, 'bookTitle': bookTitle, 'author': author, 'update': update, 'introduction': introduction}
    return json.dumps(dict, ensure_ascii=False)

算法端封装成rpc函数

python 复制代码
@dispatcher.add_method
def crawling_method(**kwargs):
    main_url = kwargs['url']
    ret = crawling(main_url)
    print("ret: " + ret)
    return ret

小说系统后端

基本的增删改查就不详细说了

定时爬取任务

java 复制代码
package com.heaven.novels.service.scheduled;

import com.heaven.novels.model.novel.NovelInfoModel;
import com.heaven.novels.service.novel.CrawServcie;
import com.heaven.novels.utils.RedisLock;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.List;

@Slf4j
@Component
public class ScheduledTask {
    @Resource
    private CrawServcie crawServcie;
    @Resource
    private RedisLock redisLock;

    @Scheduled(cron = "0 0/1 * * * ? ")
    public void crawNovelInfo() {
        log.info("excute craw novel info scheduled task");
        String url = "http://www.365kk.cc/31/31017/";
        if (redisLock.tryLock(url, url, 10)) {
            List<NovelInfoModel> novelInfoModels = crawServcie.crawNovelInfo("http://www.365kk.cc/31/31017/");
        }
    }
}

RPC调用爬虫算法

提供的爬虫接口

java 复制代码
package com.heaven.novels.controller;

import com.heaven.novels.model.common.JsonResult;
import com.heaven.novels.model.novel.NovelInfoModel;
import com.heaven.novels.service.novel.CrawServcie;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.List;

@RestController
@RequestMapping("/craw")
public class CrawController {
    @Resource
    private CrawServcie crawServcie;

    @GetMapping("/novelInfo")
    public JsonResult<NovelInfoModel> crawNovelInfo(@RequestParam("novel_url") String url) throws Throwable {
        List<NovelInfoModel> novels = crawServcie.crawNovelInfo(url);
        return JsonResult.OK(novels);
    }
}

构造RPC请求,调用爬虫算法

java 复制代码
package com.heaven.novels.service.novel.impl;

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.googlecode.jsonrpc4j.JsonRpcHttpClient;
import com.heaven.novels.constants.Constant;
import com.heaven.novels.exception.ServiceException;
import com.heaven.novels.mapper.CrawMapper;
import com.heaven.novels.model.CrawNovelModel;
import com.heaven.novels.model.novel.AlgorithmNovelInfoDTO;
import com.heaven.novels.model.novel.NovelInfoModel;
import com.heaven.novels.service.mq.Sender;
import com.heaven.novels.service.novel.CrawServcie;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.*;

@Service
public class CrawServiceImpl implements CrawServcie {
    @Resource
    private CrawMapper crawMapper;
    @Resource
    private Sender sender;

    private static final String DEFALUT_NOVEL_TYPE = "玄幻";

    @Override
    public List<NovelInfoModel> crawNovelInfo(String url) {
        List<NovelInfoModel> ret = new ArrayList<>();
        String serviceUrl = "http://localhost:4002/jsonrpc";
        try {
            URL source = new URL(serviceUrl);
            Map<String, String> headers = new HashMap<>();
            CrawNovelModel crawNovelModel = new CrawNovelModel();
            crawNovelModel.setUrl(url);
            headers.put("content-type", "application/json");

            JsonRpcHttpClient jsonRpcHttpClient = new JsonRpcHttpClient(source, headers);
            jsonRpcHttpClient.setReadTimeoutMillis(100000000);
            jsonRpcHttpClient.setConnectionTimeoutMillis(1000000000);
            String crawlingMethod = jsonRpcHttpClient.invoke("crawling_method", crawNovelModel, String.class);

            // 解析算法端响应
            JSONObject jsonObject = JSON.parseObject(crawlingMethod);
            AlgorithmNovelInfoDTO algorithmNovelInfoDto = jsonObject.toJavaObject(AlgorithmNovelInfoDTO.class);
            UUID uuid = UUID.randomUUID();
            NovelInfoModel novelInfoModel = new NovelInfoModel();
            BeanUtils.copyProperties(algorithmNovelInfoDto, novelInfoModel);
            novelInfoModel.setUuid(uuid.toString());
            novelInfoModel.setType(DEFALUT_NOVEL_TYPE);
            ret.add(novelInfoModel);

            // 存储数据库
            crawMapper.addNovel(novelInfoModel);

            // 向消息队列写入邮件消息
            sender.send(uuid.toString());
        } catch (MalformedURLException e) {
            throw new ServiceException(Constant.SERVICE_EXCEPTION_CODE, "algorithm url is fault");
        } catch (Throwable e) {
            throw new ServiceException("500", "fail to invoke algorithm");
        }
        return ret;
    }
}

RabbitMQ消息队列

RabbitMQ配置

java 复制代码
package com.heaven.novels.config;

import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitMQConfig {
    public static final String QUEUE_NAME = "craw-novel-info";

    @Bean
    public Queue queue() {
        return new Queue(QUEUE_NAME);
    }
}

消息生产者

java 复制代码
package com.heaven.novels.service.mq;

import com.heaven.novels.config.RabbitMQConfig;
import com.heaven.novels.service.mail.MailService;
import com.heaven.novels.utils.MailUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.AmqpTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;

@Slf4j
@Service
public class Sender {
    @Resource
    private AmqpTemplate rabbitTemplate;
    public void send(String msg) {
        log.info("---爬取小说,发送通知邮箱消息---");
        rabbitTemplate.convertAndSend(RabbitMQConfig.QUEUE_NAME, msg);
    }

}

消息消费者

java 复制代码
package com.heaven.novels.service.mq;

import com.heaven.novels.config.RabbitMQConfig;
import com.heaven.novels.service.mail.MailService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.AmqpTemplate;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;

@Slf4j
@Service
public class Receiver {
    @Resource
    private AmqpTemplate rabbitTemplate;
    @Resource
    private MailService mailService;

    @RabbitListener(queues = RabbitMQConfig.QUEUE_NAME)
    public void receiveMessage(String message) {
        log.info("介绍都发送邮件消息,开始发送邮件信息:" + message);
        // 分布式锁,多实例部署的时候,任务重复,所以对每个小说只执行一次
        mailService.sendMail(message);
    }
}

Redis分布式锁

Redis配置

java 复制代码
package com.heaven.novels.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {
    @Bean(name = "redisTemplate")
    public RedisTemplate<String, Object> getRedisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<String, Object>();
        redisTemplate.setConnectionFactory(factory);

        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        redisTemplate.setKeySerializer(stringRedisSerializer); // key的序列化类型

        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        // 方法过期,改为下面代码
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer); // value的序列化类型
        redisTemplate.setHashKeySerializer(stringRedisSerializer);
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}

基于setnx的分布式锁实现

java 复制代码
package com.heaven.novels.utils;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

@Component
public class RedisLock {
    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    public boolean tryLock(String key, String value, int timeout) {
        Boolean isSet = redisTemplate.opsForValue().setIfAbsent(key, value);
        if (isSet) {
            return Boolean.TRUE.equals(redisTemplate.expire(key, timeout, TimeUnit.MINUTES));
        }
        return false;
    }
}

QQ邮件发送实现

java 复制代码
package com.heaven.novels.utils;

import com.heaven.novels.model.Mail;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

@Component
@Slf4j
public class MailUtil {
    @Value("${spring.mail.username}")
    private String sender; //邮件发送者

    @Resource
    private JavaMailSender javaMailSender;


    /**
     * 发送文本邮件
     *
     * @param mail
     */
    public void sendSimpleMail(Mail mail) {
        try {
            SimpleMailMessage mailMessage = new SimpleMailMessage();
            mailMessage.setFrom(sender); //邮件发送者
            mailMessage.setTo(mail.getRecipient()); // 邮件发给的人
            mailMessage.setSubject(mail.getSubject());  // 邮件主题
            mailMessage.setText(mail.getContent());  // 邮件内容

            javaMailSender.send(mailMessage);
            log.info("邮件发送成功 收件人:{}", mail.getRecipient());
        } catch (Exception e) {
            log.error("邮件发送失败 {}", e.getMessage());
        }
    }

}

实现中碰到的问题

  1. 新建项目选择maven,然后手动导入springboot依赖和手写一些springboot启动类,启动不成功,但是点击安装后,又可以启动了。然后点击clearn清理掉target文件,然后又报找不到或者无法加载启动类。
  2. Maven运行install的时候,test类下的测试用例会自动跑
  3. 邮箱发送惊现utf-8编码的中文(ascii编码的中文),而不是中文? python使用json.dumps输出中文_json.dumps 中文-CSDN博客
  4. BeanUtils.copyProperties()实效,检查一下传入的而source和target对象属性是否都有get和set方法
  5. Mybatis插入mysql中文乱码,yml配置datasource的url最后再加上&characterEncoding=utf-8
  6. 消息队列连接localhost:5672失败,需要先安装消息队列,并启动!
  7. 安装rabbitMQ管理页面插件失败:rabbitmq-plugins enable rabbitmq_management 与安装的erlang语言版本不兼容导致的 RabbitMQ Erlang Version Requirements --- RabbitMQ 对照这个版本表,我安装的erlang是26.1.6, 所以选择rabbitmq-server-3.12.8 Erlang和RabbitMQ下载地址: Downloads - Erlang/OTP Releases · rabbitmq/rabbitmq-server (github.com)

优化技术点

  1. 基于Lua脚本、Redlock算法优化分布式锁

  2. RabbitMQ消息队列使用优化

  3. 后端服务拆分,目前异步消息服务还是和后端基本功能耦合在一起

  4. 基于Quartz框架优化定时任务

相关推荐
P.H. Infinity28 分钟前
【RabbitMQ】04-发送者可靠性
java·rabbitmq·java-rabbitmq
不能再留遗憾了5 小时前
RabbitMQ 高级特性——消息分发
分布式·rabbitmq·ruby
许苑向上9 小时前
【零基础小白】 window环境下安装RabbitMQ
rabbitmq
ktkiko1111 小时前
Java中的远程方法调用——RPC详解
java·开发语言·rpc
P.H. Infinity1 天前
【RabbitMQ】03-交换机
分布式·rabbitmq
点点滴滴的记录1 天前
RPC核心实现原理
网络·网络协议·rpc
徒步僧1 天前
ThingsBoard规则链节点:RPC Call Reply节点详解
qt·microsoft·rpc
孤蓬&听雨1 天前
RabbitMQ自动发送消息工具(自动化测试RabbitMQ)
分布式·测试工具·自动化·rabbitmq·自动发送消息
呼啦啦啦啦啦啦啦啦1 天前
RabbitMQ 七种工作模式介绍
分布式·rabbitmq
qq_203769491 天前
win11安装最新rabbitmq
分布式·rabbitmq·ruby