一:需求新增
1.需求分析
前面我们的想法是调用腾讯云的内容安全对文章的文本信息及图片进行安全检测,通过则将文章发布。但是这时候有个缺点,就是审核不能过滤一些敏感词,比如针孔摄像头、高额贷款、论文代写等,我们这时候就需要自己维护一套敏感词系统用来对文章进行检测。对于这套敏感词系统,我们可以在管理端进行删除增添的操作。
编辑
除了要对文本进行敏感词过滤之外,由于图片中可能还会存在敏感词,所以我们也需要对图片中的文字进行检测,使用到的技术是OCR。
2.技术选型
(1)文本
方案 | 说明 |
数据库模糊查询 | 效率太低 |
String.indexOf("")查找 | 数据库量大的话也是比较慢 |
全文检索 | 分词再匹配 |
DFA算法 | 确定有穷自动机(一种数据结构) |
在文字过滤系统中,为了能够应付较高的并发,需要尽量减少计算。采用DFA算法基本没有什么计算,基本是状态转移。所以选用最后一种方案。
(2)图片
方案 | 说明 |
百度OCR | 收费 |
Tesseract-OCR | Google维护的开源OCR引擎,支持Java,Python等语言调用 |
Tess4J | 封装了Tesseract-OCR ,支持Java调用 |
Tess4J封装了Tesseract-OCR,Java调用起来方便,因此选用此方案。
3.DFA
(1)原理分析
DFA全称为:Deterministic Finite Automaton,即确定有穷自动机。
其特征为:有一个有限状态集合和一些从一个状态通向另一个状态的边,每条边上标记有一个符号,其中一个状态是初态,某些状态是终态。但不同于不确定的有限自动机,DFA中不会有从同一状态出发的两条边标志有相同的符号。编辑
简单点说就是,它是是通过event和当前的state得到下一个state,即event+state=nextstate。理解为系统中有多个节点,通过传递进入的event,来确定走哪个路由至另一个节点,而节点是有限的。
存储:一次性的把所有的敏感词存储到了多个map中,就是下图表示这种结构
敏感词:冰毒、大麻、大坏蛋
编辑 其处理过程为先检索文章中每一个字符,然后查看该字符是否包含在敏感词库中,假如包含在敏感词库中则查看该字符在敏感词库中isEnd是否为0,假如不为0则继续检索下一个字符,然后继续查看该字符是否存在于敏感词库中,假如存在则查看isEnd是否为1,假如为1则说明是敏感词。举个例子,假如有这样一句话:“我不卖冰毒”,这时候就会先检索“我”字,发现不在敏感词库,然后继续检索下一个,直至到“冰”,这时候“冰”在敏感词库中,但是isEnd为0,继续检索,“毒”在敏感词库且isEnd为1,这时候检测到敏感词“冰毒”。
(2)代码实现
①创建敏感词表:
编辑
实体类:
package com.my.model.wemedia.pojos; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.io.Serializable; import java.util.Date; /** * <p> * 敏感词信息表 * </p> * * @author itheima */ @Data @TableName("wm_sensitive") public class WmSensitive implements Serializable { private static final long serialVersionUID = 1L; /** * 主键 */ @TableId(value = "id", type = IdType.ASSIGN_ID) private Integer id; /** * 敏感词 */ @TableField("sensitives") private String sensitives; /** * 创建时间 */ @TableField("created_time") private Date createdTime; }
②创建对应Mapper接口
③导入工具类
package com.my.utils.common; import java.util.*; public class SensitiveWordUtil { public static Map<String, Object> dictionaryMap = new HashMap<>(); /** * 生成关键词字典库 * @param words * @return */ public static void initMap(Collection<String> words) { if (words == null) { System.out.println("敏感词列表不能为空"); return ; } // map初始长度words.size(),整个字典库的入口字数(小于words.size(),因为不同的词可能会有相同的首字) Map<String, Object> map = new HashMap<>(words.size()); // 遍历过程中当前层次的数据 Map<String, Object> curMap = null; Iterator<String> iterator = words.iterator(); while (iterator.hasNext()) { String word = iterator.next(); curMap = map; int len = word.length(); for (int i =0; i < len; i++) { // 遍历每个词的字 String key = String.valueOf(word.charAt(i)); // 当前字在当前层是否存在, 不存在则新建, 当前层数据指向下一个节点, 继续判断是否存在数据 Map<String, Object> wordMap = (Map<String, Object>) curMap.get(key); if (wordMap == null) { // 每个节点存在两个数据: 下一个节点和isEnd(是否结束标志) wordMap = new HashMap<>(2); wordMap.put("isEnd", "0"); curMap.put(key, wordMap); } curMap = wordMap; // 如果当前字是词的最后一个字,则将isEnd标志置1 if (i == len -1) { curMap.put("isEnd", "1"); } } } dictionaryMap = map; } /** * 搜索文本中某个文字是否匹配关键词 * @param text * @param beginIndex * @return */ private static int checkWord(String text, int beginIndex) { if (dictionaryMap == null) { throw new RuntimeException("字典不能为空"); } boolean isEnd = false; int wordLength = 0; Map<String, Object> curMap = dictionaryMap; int len = text.length(); // 从文本的第beginIndex开始匹配 for (int i = beginIndex; i < len; i++) { String key = String.valueOf(text.charAt(i)); // 获取当前key的下一个节点 curMap = (Map<String, Object>) curMap.get(key); if (curMap == null) { break; } else { wordLength ++; if ("1".equals(curMap.get("isEnd"))) { isEnd = true; } } } if (!isEnd) { wordLength = 0; } return wordLength; } /** * 获取匹配的关键词和命中次数 * @param text * @return */ public static Map<String, Integer> matchWords(String text) { Map<String, Integer> wordMap = new HashMap<>(); int len = text.length(); for (int i = 0; i < len; i++) { int wordLength = checkWord(text, i); if (wordLength > 0) { String word = text.substring(i, i + wordLength); // 添加关键词匹配次数 if (wordMap.containsKey(word)) { wordMap.put(word, wordMap.get(word) + 1); } else { wordMap.put(word, 1); } i += wordLength - 1; } } return wordMap; } /* public static void main(String[] args) { List<String> list = new ArrayList<>(); list.add("法轮"); list.add("法轮功"); list.add("冰毒"); initMap(list); String content="我是一个好人,并不会卖冰毒,也不操练法轮功,我真的不卖冰毒"; Map<String, Integer> map = matchWords(content); System.out.println(map); } */ }
4.OCR
(1)原理分析
OCR (Optical Character Recognition,光学字符识别)是指电子设备(例如扫描仪或数码相机)检查纸上打印的字符,通过检测暗、亮的模式确定其形状,然后用字符识别方法将形状翻译成计算机文字的过程。
(2)代码实现
①在tbug-headlines-common中导入如下依赖
<dependency> <groupId>net.sourceforge.tess4j</groupId> <artifactId>tess4j</artifactId> <version>4.1.1</version> </dependency>
②封装成工具类
package com.my.common.tess4j; import lombok.Getter; import lombok.Setter; import net.sourceforge.tess4j.ITesseract; import net.sourceforge.tess4j.Tesseract; import net.sourceforge.tess4j.TesseractException; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import java.awt.image.BufferedImage; @Getter @Setter @Component @ConfigurationProperties(prefix = "tess4j") public class Tess4jClient { private String dataPath; private String language; public String doOCR(BufferedImage image) throws TesseractException { //创建Tesseract对象 ITesseract tesseract = new Tesseract(); //设置字体库路径 tesseract.setDatapath(dataPath); //中文识别 tesseract.setLanguage(language); //执行ocr识别 String result = tesseract.doOCR(image); //替换回车和tal键 使结果为一行 result = result.replaceAll("\\r|\\n", "-").replaceAll(" ", ""); return result; } }
③在spring.factories配置中添加该类,完整如下:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.my.common.exception.ExceptionCatch,\ com.my.common.swagger.SwaggerConfiguration,\ com.my.common.swagger.Swagger2Configuration,\ com.my.common.tencentcloud.TextDetection,\ com.my.common.tencentcloud.ImageDetection,\ com.my.common.tess4j.Tess4jClient,\
④在tbug-headlines-wemedia中添加如下配置
tess4j: data-path: D:\headlinesPro\tessdata language: chi_sim
注意文件路径要填自己的路径
二:完整审核流程
1.审核类
package com.my.wemedia.service.impl; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.my.apis.article.IArticleClient; import com.my.common.tencentcloud.ImageDetection; import com.my.common.tencentcloud.TextDetection; import com.my.common.tess4j.Tess4jClient; import com.my.file.service.FileStorageService; import com.my.model.article.dtos.ArticleDto; import com.my.model.common.dtos.ResponseResult; import com.my.model.wemedia.pojos.WmChannel; import com.my.model.wemedia.pojos.WmNews; import com.my.model.wemedia.pojos.WmSensitive; import com.my.model.wemedia.pojos.WmUser; import com.my.utils.common.SensitiveWordUtil; import com.my.wemedia.mapper.WmSensitiveMapper; import com.my.wemedia.service.WmAutoScanService; import com.my.wemedia.service.WmChannelService; import com.my.wemedia.service.WmNewsService; import com.my.wemedia.service.WmUserService; import com.tencentcloudapi.common.exception.TencentCloudSDKException; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang.StringUtils; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.util.*; import java.util.stream.Collectors; @Slf4j @Service @Transactional public class WmAutoScanServiceImpl implements WmAutoScanService { @Autowired private WmNewsService wmNewsService; @Autowired private TextDetection textDetection; @Autowired private ImageDetection imageDetection; /** * 自动审核文章文本及图片 * @param id */ @Override @Async //表明这是一个异步方法 public void AutoScanTextAndImage(Integer id) throws Exception { log.info("开始进行文章审核..."); // Thread.sleep(300); //休眠300毫秒,以保证能够获取到数据库中的数据 WmNews wmNews = wmNewsService.getById(id); if(wmNews == null) { throw new RuntimeException("WmAutoScanServiceImpl-文章信息不存在"); } if(wmNews.getStatus().equals(WmNews.Status.SUBMIT.getCode())) { //1.提取文章文本及图片 Map<String,Object> map = getTextAndImages(wmNews); //2.检测文本 //2.1提取文本 String content = ((StringBuilder) map.get("content")).toString(); //2.2自管理敏感词过滤 boolean SHandleResult = handleSensitiveScan(content,wmNews); if(!SHandleResult) return; //2.3调用腾讯云进行文本检测 Boolean THandleResult = handleTextScan(content, wmNews); if(!THandleResult) return; //3.检测图片 //3.1提取图片 List<String> imageUrl = (List<String>) map.get("images"); //3.2调用腾讯云对图片进行检测 Boolean IHandleResult = handleImageScan(imageUrl, wmNews); if(!IHandleResult) return; //4,审核成功 //4.1保存文章 log.info("检测到文章无违规内容"); ResponseResult responseResult = saveAppArticle(wmNews); if(!responseResult.getCode().equals(200)) { throw new RuntimeException("WmAutoScanServiceImpl-文章审核,保存文章失败"); } //4.2回填article_id wmNews.setArticleId((Long) responseResult.getData()); wmNews.setStatus(WmNews.Status.PUBLISHED.getCode()); wmNews.setReason("审核成功"); wmNewsService.updateById(wmNews); } } /** * 提取文章文本及图片信息 * @param wmNews * @return */ private Map<String,Object> getTextAndImages(WmNews wmNews) { //存储纯文本 StringBuilder text = new StringBuilder(); //存储图片链接 List<String> imageUrl = new ArrayList<>(); //1.从文章内容中提取文本及图片 if(StringUtils.isNotBlank(wmNews.getContent())) { List<Map> maps = JSON.parseArray(wmNews.getContent(),Map.class); for (Map map : maps) { if(map.get("type").equals("text")) { //文本 text.append(map.get("value")); } if(map.get("type").equals("image")) { //图片 imageUrl.add((String) map.get("value")); } } } //2.从封面中提取图片 if(StringUtils.isNotBlank(wmNews.getImages())) { for (String imageurl : wmNews.getImages().split(",")) { imageUrl.add(imageurl); } } //3.结果封装 Map<String,Object> map = new HashMap<>(); map.put("content",text); map.put("images",imageUrl); return map; } @Autowired private WmSensitiveMapper wmSensitiveMapper; /** * 自管理敏感词过滤 * @param content * @param wmNews * @return */ private boolean handleSensitiveScan(String content,WmNews wmNews) { boolean flag = true; //1.获取所有的敏感词 List<WmSensitive> wmSensitives = wmSensitiveMapper.selectList(Wrappers.<WmSensitive>lambdaQuery().select(WmSensitive::getSensitives)); List<String> sensitiveList = wmSensitives.stream().map(WmSensitive::getSensitives).collect(Collectors.toList()); //2.初始化敏感词库 SensitiveWordUtil.initMap(sensitiveList); //3.检测文章是否包含敏感词 Map<String, Integer> map = SensitiveWordUtil.matchWords(content); if(map.size() > 0) { log.info("当前文章存在敏感词:{}",map.keySet()); flag = false; wmNews.setStatus(WmNews.Status.FAIL.getCode()); wmNews.setReason("当前文章存在敏感词"+map.keySet()); wmNewsService.updateById(wmNews); } return flag; } /** * 文本检测 * @param content * @param wmNews * @return */ private Boolean handleTextScan(String content,WmNews wmNews) throws TencentCloudSDKException { log.info("开始检测文章文本内容..."); boolean flag = true; //1.文本为空 if(StringUtils.isBlank(content) && StringUtils.isBlank(wmNews.getTitle())) { return true; } //2.调用腾讯云对文本进行检测 JSONObject resultJson = textDetection.greenTextDetection(content + wmNews.getTitle()); //审核不通过 if(resultJson.get("Suggestion").equals("Block")) { log.warn("文章存在违规文本,审核不成功"); flag = false; wmNews.setStatus(WmNews.Status.FAIL.getCode()); wmNews.setReason("文章存在违规文本,审核不成功"); wmNewsService.updateById(wmNews); } //自动审核不确定 if(resultJson.get("Suggestion").equals("Review")) { log.info("文章存在不确定文本"); flag = false; wmNews.setStatus(WmNews.Status.ADMIN_AUTH.getCode()); wmNews.setReason("文章存在不确定文本"); wmNewsService.updateById(wmNews); } return flag; } @Autowired private FileStorageService fileStorageService; @Autowired private Tess4jClient tess4jClient; /** * 图片检测 * @param list * @param wmNews * @return * @throws TencentCloudSDKException */ private Boolean handleImageScan(List<String> list, WmNews wmNews) throws TencentCloudSDKException { log.info("开始检测文章图片内容..."); boolean flag = true; //图片信息为空 if(list.size() == 0) { return true; } //1.图片去重 List<String> imageList = list.stream().distinct().collect(Collectors.toList()); //2.调用腾讯云接口逐一检测 for (String image : imageList) { try { //敏感词检测 //1.下载minio图片文件 byte[] bytes = fileStorageService.downLoadFile(image); //2.从byte[]转换为bufferedImage ByteArrayInputStream in = new ByteArrayInputStream(bytes); BufferedImage imageFile = ImageIO.read(in); //3.识别图片文字 if(imageFile != null) { String result = tess4jClient.doOCR(imageFile); //4.审核图片是否包含敏感词 boolean isSensitive = handleSensitiveScan(result,wmNews); if(!isSensitive) { return false; } } } catch (Exception e) { e.printStackTrace(); } //腾讯云图片安全检测 JSONObject resultJson = imageDetection.greenImageDetection(image); //审核不通过 if(resultJson.get("Suggestion").equals("Block")) { log.warn("文章存在违规图片,审核不成功"); flag = false; wmNews.setStatus(WmNews.Status.FAIL.getCode()); wmNews.setReason("文章存在违规图片,审核不成功"); wmNewsService.updateById(wmNews); } //自动审核不确定 if(resultJson.get("Suggestion").equals("Review")) { log.info("文章存在不确定图片"); flag = false; wmNews.setStatus(WmNews.Status.ADMIN_AUTH.getCode()); wmNews.setReason("文章存在不确定图片"); wmNewsService.updateById(wmNews); } } return flag; } @Autowired private WmChannelService wmChannelService; @Autowired private WmUserService wmUserService; @Resource private IArticleClient iArticleClient; /** * 保存App端文章数据 * @param wmNews * @return */ @Override public ResponseResult saveAppArticle(WmNews wmNews) { ArticleDto articleDto = new ArticleDto(); //1.属性拷贝 BeanUtils.copyProperties(wmNews,articleDto); //2.设置文章布局 articleDto.setLayout(wmNews.getType()); //3.设置频道信息 WmChannel channel = wmChannelService.getById(wmNews.getChannelId()); if(channel != null) { articleDto.setChannelName(channel.getName()); } //4.设置作者信息 articleDto.setAuthorId(wmNews.getUserId().longValue()); WmUser wmUser = wmUserService.getById(wmNews.getUserId()); if(wmUser != null) { articleDto.setAuthorName(wmUser.getName()); } //5.设置文章id if(wmNews.getArticleId() != null) { articleDto.setId(wmNews.getArticleId()); } //6.设置创建时间 articleDto.setCreatedTime(new Date()); //7.保存App端文章 log.info("异步调用保存文章至App端"); return iArticleClient.saveArticle(articleDto); } }
2.功能调用
在文章提交实现类中添加对自动审核方法的调用(见第五步)
/** * 提交文章 * @param dto * @return */ @Override public ResponseResult submitNews(WmNewsDto dto) { //1.参数校验 if(dto == null || dto.getContent().length() == 0) { return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID); } //2.保存或修改文章 //2.1属性拷贝 WmNews wmNews = new WmNews(); BeanUtils.copyProperties(dto,wmNews); //2.2设置封面图片 if(dto.getImages() != null && dto.getImages().size() != 0) { String images = StringUtils.join(dto.getImages(), ","); wmNews.setImages(images); } //2.3封面类型为自动 if(dto.getType().equals(WemediaConstants.WM_NEWS_TYPE_AUTO)) { wmNews.setType(null); } saveOrUpdateWmNews(wmNews); //3.判断是否为草稿 if(dto.getStatus().equals(WmNews.Status.NORMAL.getCode())) { //直接保存结束 return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS); } //4.不是草稿 //4.1保存文章图片素材与文章关系 //4.1.1提取图片素材列表 List<String> imagesList = getImagesList(dto); //4.1.2保存 saveRelatedImages(imagesList,wmNews.getId(),WemediaConstants.WM_CONTENT_REFERENCE); //4.2保存封面图片和文章关系 saveRelatedCover(dto,imagesList,wmNews); //5.审核文章(异步调用) wmAutoScanService.AutoScanTextAndImage(wmNews.getId()); return ResponseResult.okResult(AppHttpCodeEnum.SUCCESS); }
下篇预告:实现文章定时发布