@ -2,237 +2,408 @@ package com.tamguo;
import com.baomidou.mybatisplus.mapper.Condition ;
import com.baomidou.mybatisplus.plugins.Page ;
// 引入MyBatis-Plus框架中的相关类, Condition类用于构建数据库查询条件, 方便按照特定规则筛选、排序等操作;
// Page类用于实现分页查询相关功能, 可指定每页数据量、当前页码等信息, 便于对大量数据进行分页处理。
import com.tamguo.config.redis.CacheService ;
// 引入自定义的基于Redis的缓存服务类, 推测其提供了如缓存数据读写、计数等与Redis缓存交互的功能, 在后续代码中可能用于辅助数据处理, 比如生成唯一标识等操作。
import com.tamguo.dao.* ;
// 引入多个自定义的数据访问层( DAO) 接口, 这些接口分别对应不同实体( 如Question、CrawlerQuestion、CrawlerPaper等) 在数据库中的操作,
// 其实现类会具体实现像数据的插入、查询、更新等数据库交互逻辑,通过依赖注入获取相应实现类实例后就能操作数据库了。
import com.tamguo.model.* ;
import com.tamguo.model.enums.QuestionType ;
import com.tamguo.model.vo.QuestionVo ;
// 引入自定义的实体类、枚举类以及视图对象类。实体类对应数据库表结构, 包含各表字段对应的属性; QuestionType枚举类用于明确题目类型的不同取值情况, 便于业务逻辑区分;
// QuestionVo这类视图对象类通常用于在不同方法间临时封装和传递数据, 比如在网页爬取解析过程中传递题目相关信息。
import com.xuxueli.crawler.XxlCrawler ;
import com.xuxueli.crawler.conf.XxlCrawlerConf ;
import com.xuxueli.crawler.loader.strategy.HtmlUnitPageLoader ;
import com.xuxueli.crawler.parser.PageParser ;
import com.xuxueli.crawler.rundata.RunData ;
import com.xuxueli.crawler.util.FileUtil ;
// 引入XxlCrawler框架相关类, XxlCrawler用于构建和配置爬虫实例; XxlCrawlerConf可能包含爬虫的一些默认配置参数等; HtmlUnitPageLoader用于加载网页内容;
// PageParser用于定义如何解析爬取到的页面内容; RunData用于管理爬虫运行时的数据; FileUtil提供文件下载等文件相关操作的功能, 这些类协同实现网页爬取及后续数据处理。
import org.apache.commons.lang3.StringUtils ;
// 引入Apache Commons Lang3库中的StringUtils类, 用于方便地进行字符串相关操作, 比如判断字符串是否为空、拼接字符串等, 在代码中多处用于对各种文本属性的处理。
import org.jsoup.nodes.Document ;
import org.jsoup.nodes.Element ;
// 引入Jsoup库中的Document和Element类, 用于解析HTML文档, Document代表整个HTML页面文档, Element则用于表示文档中的具体元素,
// 在爬取网页后解析页面内容提取数据时会用到它们。
import org.junit.Test ;
import org.junit.runner.RunWith ;
// 引入JUnit测试框架中的注解, @Test用于标记下面的方法为测试方法, JUnit运行时会执行被标记的方法来进行测试; @RunWith用于指定测试运行的运行器,
// 这里配置使其能运行在符合Spring框架要求的环境下, 便于结合Spring相关功能开展测试。
import org.springframework.beans.factory.annotation.Autowired ;
import org.springframework.boot.test.context.SpringBootTest ;
import org.springframework.test.context.junit4.SpringRunner ;
// Spring框架相关注解, @Autowired用于实现自动装配( 依赖注入) , 让Spring容器把对应的Bean实例注入到标注的变量中; @SpringBootTest表明这是一个Spring Boot应用的测试类,
// 会启动完整的应用上下文; SpringRunner是Spring为JUnit 4提供的运行器, 结合@RunWith使用能让测试运行在Spring构建的测试环境里。
import java.io.File ;
import java.text.DecimalFormat ;
import java.text.SimpleDateFormat ;
import java.util.* ;
// 引入Java标准库中的常用类, 用于文件操作、日期格式化、数字格式化以及集合操作等, 满足代码中诸如创建文件目录、格式化日期、生成编号以及处理数据集合等需求。
// PaperQuestionCrawler类是一个基于Spring Boot和JUnit框架构建的测试类, 其主要功能是利用XxlCrawler框架爬取试卷题目相关信息,
// 对爬取到的数据进行多方面处理(如关联其他实体信息、处理题目内容中的图片等)后,将题目数据存储到数据库中。
@RunWith ( SpringRunner . class )
@SpringBootTest
public class PaperQuestionCrawler {
// 使用@Autowired注解自动注入QuestionMapper接口的实现类实例, 通过该实例可执行与题目( Question) 相关的数据库操作,
// 例如将整理好的题目信息插入到数据库对应的题目表中,实现数据的持久化存储。
@Autowired
QuestionMapper questionMapper ;
// 自动注入CrawlerQuestionMapper接口的实现类实例, 用于处理与爬取题目相关的数据库操作, 比如可能涉及保存爬取题目过程中的一些额外信息等, 辅助整个题目爬取及存储流程。
@Autowired
CrawlerQuestionMapper crawlerQuestionMapper ;
// 注入CrawlerPaperMapper接口实现类实例, 借助它能操作与爬取试卷相关的数据, 比如依据题目所在的URL去查找对应的试卷信息等, 方便建立题目与试卷之间的关联关系。
@Autowired
CrawlerPaperMapper crawlerPaperMapper ;
// 注入ChapterMapper接口实现类实例, 虽然在当前展示的代码中未明确体现其详细使用场景, 但从名称推测它用于执行与章节( Chapter) 相关的数据库操作,
// 或许在题目与章节关联等业务逻辑处理中会用到。
@Autowired
ChapterMapper chapterMapper ;
// 注入CourseMapper接口实现类实例, 用于操作课程( Course) 相关的数据库操作, 例如通过试卷关联获取题目所属课程信息, 进而准确设置题目实体中的课程相关属性。
@Autowired
CourseMapper courseMapper ;
// 注入SubjectMapper接口实现类实例, 用于操作学科( Subject) 相关的数据库操作, 同样是为了获取题目所属学科的相关信息, 完善题目实体对象中关于学科的属性设置。
@Autowired
SubjectMapper subjectMapper ;
// 注入PaperMapper接口实现类实例, 用于执行试卷( Paper) 相关的数据库操作, 像根据试卷ID获取试卷详细信息等, 辅助题目数据与试卷数据之间的关联处理及相关属性赋值。
@Autowired
PaperMapper paperMapper ;
// 注入自定义的缓存服务类实例, 用于与Redis缓存进行交互, 在后续代码中可能用于对部分数据进行缓存以提升性能, 或者实现如文件编号自增等特定的数据处理功能。
@Autowired
CacheService cacheService ;
// 定义文件编号的格式模板, 是一个固定长度( 9位) 的用0占位的数字字符串, 用于后续按照一定规则生成文件编号, 确保编号格式的一致性和规范性。
private static final String FILES_NO_FORMAT = "000000000" ;
// 定义文件路径的前缀字符串, 此处为“shengwu”, 用于构建完整的文件存储路径, 可能表示文件所属的分类或主题, 具体含义取决于业务逻辑设定。
private static final String FILES_PREFIX = "shengwu" ;
// 定义课程ID, 固定为“shengwu”, 用于标识题目所属的课程, 方便在数据处理过程中关联题目与课程, 以及在构建文件路径等操作中作为关键标识使用。
private static final String COURSE_ID = "shengwu" ;
// 用于存储爬虫运行时的数据对象, 通过XxlCrawler框架管理爬虫运行过程中的各种相关数据, 例如已访问的URL、待访问的URL等,
// 在爬虫启动以及后续的数据处理过程中,这个对象中的数据会不断更新和被使用。
private RunData runData ;
// 使用@Test注解标记的测试方法, 名为crawlerQuestion, 该方法实现了利用XxlCrawler框架进行题目信息爬取、对爬取到的数据进行全面处理以及最终将处理好的数据存储到数据库的完整逻辑,
// 同时涵盖了对题目内容中图片的下载以及相关URL替换等复杂操作。
@SuppressWarnings ( "unchecked" )
@Test
public void crawlerQuestion ( ) {
// 创建一个XxlCrawler实例的构建器, 通过链式调用一系列方法来配置爬虫的各个参数和行为, 完成配置后调用build方法就能得到最终可用的爬虫实例。
XxlCrawler crawler = new XxlCrawler . Builder ( )
. setAllowSpread ( false )
. setThreadCount ( 1 )
. setFailRetryCount ( 5 )
. setPageLoader ( new HtmlUnitPageLoader ( ) )
. setPageParser ( new PageParser < QuestionVo > ( ) {
@Override
public void parse ( Document html , Element pageVoElement , QuestionVo questionVo ) {
if ( StringUtils . isEmpty ( questionVo . getContent ( ) ) ) {
runData . addUrl ( html . baseUri ( ) ) ;
return ;
}
CrawlerPaperEntity condition = new CrawlerPaperEntity ( ) ;
condition . setQuestionUrl ( html . baseUri ( ) ) ;
System . out . println ( html . baseUri ( ) ) ;
CrawlerPaperEntity crawlerPaper = crawlerPaperMapper . selectOne ( condition ) ;
PaperEntity paper = paperMapper . selectById ( crawlerPaper . getPaperId ( ) ) ;
CourseEntity course = courseMapper . selectById ( paper . getCourseId ( ) ) ;
SubjectEntity subject = subjectMapper . selectById ( paper . getSubjectId ( ) ) ;
QuestionType questionType = QuestionType . getQuestionType ( questionVo . getQuestionType ( ) ) ;
QuestionEntity question = new QuestionEntity ( ) ;
if ( questionType = = QuestionType . DANXUANTI ) {
if ( ! StringUtils . isEmpty ( questionVo . getQueoptions ( ) ) ) {
question . setContent ( questionVo . getContent ( ) + questionVo . getQueoptions ( ) ) ;
} else {
question . setContent ( questionVo . getContent ( ) ) ;
}
} else {
question . setContent ( questionVo . getContent ( ) ) ;
}
question . setAnalysis ( questionVo . getAnalysis ( ) ) ;
if ( StringUtils . isEmpty ( question . getAnalysis ( ) ) ) {
question . setAnalysis ( "<p> <span> 略 </span> <br> </p>" ) ;
}
question . setAnswer ( questionVo . getAnswer ( ) ) ;
question . setAuditStatus ( "1" ) ;
question . setChapterId ( "" ) ;
question . setCourseId ( course . getId ( ) ) ;
question . setPaperId ( paper . getId ( ) ) ;
question . setQuestionType ( questionType . getValue ( ) . toString ( ) ) ;
if ( questionVo . getReviewPoint ( ) ! = null & & questionVo . getReviewPoint ( ) . size ( ) > 0 ) {
question . setReviewPoint ( StringUtils . join ( questionVo . getReviewPoint ( ) . toArray ( ) , "," ) ) ;
}
// 处理分数
if ( questionVo . getScore ( ) ! = null ) {
if ( questionVo . getScore ( ) . contains ( "分" ) ) {
question . setScore ( questionVo . getScore ( ) ) ;
}
if ( questionVo . getScore ( ) . contains ( "年" ) ) {
question . setYear ( questionVo . getScore ( ) ) ;
}
}
if ( questionVo . getYear ( ) ! = null ) {
if ( questionVo . getYear ( ) . contains ( "年" ) ) {
question . setYear ( questionVo . getYear ( ) ) ;
}
}
question . setSubjectId ( subject . getId ( ) ) ;
if ( questionVo . getAnswerImages ( ) ! = null & & questionVo . getAnswerImages ( ) . size ( ) > 0 ) {
Set < String > imagesSet = new HashSet < String > ( questionVo . getAnswerImages ( ) ) ;
for ( String img : imagesSet ) {
// 下载图片文件
String fileName = getFileName ( img ) ;
String filePath = getFilePath ( ) ;
String fileDatePath = getFileDatePath ( ) ;
File dir = new File ( filePath + fileDatePath + "/" ) ;
if ( ! dir . exists ( ) )
dir . mkdirs ( ) ;
boolean ret = FileUtil . downFile ( img , XxlCrawlerConf . TIMEOUT_MILLIS_DEFAULT , filePath + fileDatePath + "/" , fileName ) ;
System . out . println ( "down images " + ( ret ? "success" : "fail" ) + ": " + img ) ;
// 替换URL
question . setAnswer ( question . getAnswer ( ) . replace ( img , "/files/paper/" + COURSE_ID + '/' + fileDatePath + "/" + fileName ) ) ;
}
question . setAnswer ( question . getAnswer ( ) ) ;
}
if ( questionVo . getAnalysisImages ( ) ! = null & & questionVo . getAnalysisImages ( ) . size ( ) > 0 ) {
Set < String > imagesSet = new HashSet < String > ( questionVo . getAnalysisImages ( ) ) ;
for ( String img : imagesSet ) {
// 下载图片文件
String fileName = getFileName ( img ) ;
String filePath = getFilePath ( ) ;
String fileDatePath = getFileDatePath ( ) ;
File dir = new File ( filePath + fileDatePath + "/" ) ;
if ( ! dir . exists ( ) )
dir . mkdirs ( ) ;
boolean ret = FileUtil . downFile ( img , XxlCrawlerConf . TIMEOUT_MILLIS_DEFAULT , filePath + fileDatePath + "/" , fileName ) ;
System . out . println ( "down images " + ( ret ? "success" : "fail" ) + ": " + img ) ;
// 替换URL
question . setAnalysis ( question . getAnalysis ( ) . replace ( img , "/files/paper/" + COURSE_ID + '/' + fileDatePath + "/" + fileName ) ) ;
}
question . setAnalysis ( question . getAnalysis ( ) ) ;
}
if ( questionVo . getContentImages ( ) ! = null & & questionVo . getContentImages ( ) . size ( ) > 0 ) {
Set < String > imagesSet = new HashSet < String > ( questionVo . getContentImages ( ) ) ;
for ( String img : imagesSet ) {
// 下载图片文件
String fileName = getFileName ( img ) ;
String filePath = getFilePath ( ) ;
String fileDatePath = getFileDatePath ( ) ;
File dir = new File ( filePath + fileDatePath + "/" ) ;
if ( ! dir . exists ( ) ) {
dir . mkdirs ( ) ;
}
boolean ret = FileUtil . downFile ( img , XxlCrawlerConf . TIMEOUT_MILLIS_DEFAULT , filePath + fileDatePath + "/" , fileName ) ;
System . out . println ( "down images " + ( ret ? "success" : "fail" ) + ": " + img ) ;
// 替换URL
question . setContent ( question . getContent ( ) . replace ( img , "/files/paper/" + COURSE_ID + '/' + fileDatePath + "/" + fileName ) ) ;
}
question . setContent ( question . getContent ( ) ) ;
}
// 处理图片
question . setSourceType ( "baidu" ) ;
question . setSourceUrl ( html . baseUri ( ) ) ;
questionMapper . insert ( question ) ;
}
public String getFileName ( String img ) {
return getFileNo ( ) + img . substring ( img . lastIndexOf ( "." ) ) ;
}
private String getFilePath ( ) {
return "/home/webdata/files/paper/" + COURSE_ID + "/" ;
}
private String getFileDatePath ( ) {
SimpleDateFormat sdf = new SimpleDateFormat ( "yyyyMMddHH" ) ;
String format = sdf . format ( new Date ( ) ) ;
return format ;
}
private String getFileNo ( ) {
SimpleDateFormat sdf = new SimpleDateFormat ( "yyyyMMddHH" ) ;
String format = sdf . format ( new Date ( ) ) ;
DecimalFormat df = new DecimalFormat ( FILES_NO_FORMAT ) ;
String key = FILES_PREFIX + format ;
Long incr = cacheService . incr ( key ) ;
String avatorNo = FILES_PREFIX + df . format ( incr ) ;
return avatorNo ;
}
} ) . build ( ) ;
runData = crawler . getRunData ( ) ;
int page = 1 ;
int pageSize = 1000 ;
while ( true ) {
Page < CrawlerPaperEntity > questionPage = new Page < CrawlerPaperEntity > ( page , pageSize ) ;
List < CrawlerPaperEntity > questionList = crawlerPaperMapper . selectPage ( questionPage , Condition . create ( ) . orderAsc ( Arrays . asList ( "paper_id" , "queindex" ) ) ) ;
for ( int i = 0 ; i < questionList . size ( ) ; i + + ) {
runData . addUrl ( questionList . get ( i ) . getQuestionUrl ( ) ) ;
}
page + + ;
if ( questionList . size ( ) < 1000 ) {
break ;
// 设置是否允许爬虫自动根据页面中的链接扩展爬取范围, 设置为false表示仅按照预先设定的初始URL进行爬取, 不会主动去爬取页面中发现的其他链接指向的页面,
// 若设为true, 则爬虫会自动依据页面内的超链接等去爬取更多相关页面, 但需谨慎使用, 以免出现过度爬取或不符合预期的情况。
. setAllowSpread ( false )
// 设置爬虫运行时使用的线程数量, 这里配置为1, 表示采用单线程方式来执行爬取操作, 可根据实际情况( 如目标网站的负载能力、爬取效率需求等) 调整线程数量,
// 不过线程过多可能会给目标网站带来较大压力,甚至触发反爬虫机制等问题。
. setThreadCount ( 1 )
// 设置当爬取某个页面失败时的重试次数, 此处设定为5次, 意味着如果在尝试爬取某个URL对应的页面出现失败情况时, 爬虫会自动再次尝试爬取该页面, 最多重试5次,
// 这样能在一定程度上提高爬取的成功率,尽量保证可以完整获取到需要的题目相关信息。
. setFailRetryCount ( 5 )
// 设置页面加载器为HtmlUnitPageLoader, 即利用基于HtmlUnit的方式来加载网页内容, 它能够模拟浏览器的行为获取页面的HTML文档,
// 以便后续可以顺利地对获取到的页面进行解析以及提取相关数据等操作。
. setPageLoader ( new HtmlUnitPageLoader ( ) )
// 设置页面解析器, 通过匿名内部类实现PageParser接口, 并重新实现parse方法, 以此来定义针对爬取到的页面内容具体的解析逻辑,
// 根据页面的不同情况( 如包含的元素、文本内容等) 进行相应的数据提取、整理以及后续处理操作, 下面的parse方法内就是具体的解析逻辑实现部分。
. setPageParser ( new PageParser < QuestionVo > ( ) {
@Override
public void parse ( Document html , Element pageVoElement , QuestionVo questionVo ) {
// 判断题目Vo对象中的题目内容( questionVo.getContent())是否为空字符串,如果为空,说明当前页面可能未正确获取到题目内容,
// 则将当前页面的URL添加到待访问的URL列表中( 通过runData.addUrl方法) , 以便后续可能再次尝试爬取该URL对应的页面, 然后直接返回, 不再进行后续的题目数据处理流程。
if ( StringUtils . isEmpty ( questionVo . getContent ( ) ) ) {
runData . addUrl ( html . baseUri ( ) ) ;
return ;
}
// 创建一个CrawlerPaperEntity对象, 用于构建查询数据库的条件, 这里以题目所在页面的URL( html.baseUri())作为关键条件,
// 目的是通过这个URL去数据库中查找对应的爬取试卷相关的实体信息, 以便后续建立题目与试卷之间的关联关系。
CrawlerPaperEntity condition = new CrawlerPaperEntity ( ) ;
condition . setQuestionUrl ( html . baseUri ( ) ) ;
// 在控制台打印当前页面的URL, 可能是为了方便在爬取过程中查看正在处理的页面情况, 有助于调试和监控爬取的进度以及了解数据来源等情况。
System . out . println ( html . baseUri ( ) ) ;
// 使用注入的crawlerPaperMapper( CrawlerPaperMapper接口的实现类实例) 的selectOne方法, 根据前面构建的查询条件condition,
// 从数据库中查询并获取对应的CrawlerPaperEntity对象, 也就是得到该题目所属的爬取试卷的相关详细信息。
CrawlerPaperEntity crawlerPaper = crawlerPaperMapper . selectOne ( condition ) ;
// 通过注入的paperMapper( PaperMapper接口的实现类实例) 的selectById方法, 依据从前面获取到的爬取试卷的ID( 从crawlerPaper对象中获取) ,
// 从数据库中查询出对应的PaperEntity对象, 从而得到完整的试卷信息, 为后续设置题目与试卷相关的属性做准备。
PaperEntity paper = paperMapper . selectById ( crawlerPaper . getPaperId ( ) ) ;
// 同样地, 利用注入的courseMapper( CourseMapper接口的实现类实例) 的selectById方法, 按照试卷所属的课程ID( 从paper对象中获取) ,
// 从数据库中查询出对应的CourseEntity对象, 获取题目所属的课程相关信息, 用于后续准确设置题目实体中的课程属性。
CourseEntity course = courseMapper . selectById ( paper . getCourseId ( ) ) ;
// 按照类似的逻辑, 使用注入的subjectMapper( SubjectMapper接口的实现类实例) 的selectById方法, 根据试卷所属的学科ID( 从paper对象中获取) ,
// 从数据库中查询出SubjectEntity对象, 以此得到题目所属的学科信息, 进一步完善题目实体对象的属性赋值。
SubjectEntity subject = subjectMapper . selectById ( paper . getSubjectId ( ) ) ;
// 通过QuestionType枚举类的静态方法getQuestionType, 依据题目Vo对象中获取的题目类型名称( questionVo.getQuestionType()) ,
// 获取对应的QuestionType枚举值, 这样就能在业务逻辑中明确区分题目具体属于哪种类型, 方便后续根据不同类型题目进行差异化的处理逻辑。
QuestionType questionType = QuestionType . getQuestionType ( questionVo . getQuestionType ( ) ) ;
// 创建一个QuestionEntity对象, 用于封装要插入到数据库中的题目信息, 后续会把这个对象中的各项数据持久化存储到数据库对应的题目表中。
QuestionEntity question = new QuestionEntity ( ) ;
// 根据题目类型来设置题目内容, 如果题目类型是单选题( 通过判断questionType是否等于QuestionType.DANXUANTI) ,
// 并且题目Vo对象中的题目选项( questionVo.getQueoptions())不为空字符串,那么将题目内容和题目选项拼接起来作为最终的题目内容;
// 若题目选项为空, 则直接使用题目Vo中的题目内容作为最终题目内容; 对于非单选题类型, 则直接使用题目Vo中的题目内容设置即可。
if ( questionType = = QuestionType . DANXUANTI ) {
if ( ! StringUtils . isEmpty ( questionVo . getQueoptions ( ) ) ) {
question . setContent ( questionVo . getContent ( ) + questionVo . getQueoptions ( ) ) ;
} else {
question . setContent ( questionVo . getContent ( ) ) ;
}
} else {
question . setContent ( questionVo . getContent ( ) ) ;
}
// 设置题目解析内容, 从题目Vo对象中获取解析内容赋值给QuestionEntity对象的相应属性, 如果获取到的解析内容为空字符串,
// 则设置一个默认的简略解析内容(<p> <span> 略 </span> <br> </p>),以保证题目解析信息在数据库中具有一定的完整性。
question . setAnalysis ( questionVo . getAnalysis ( ) ) ;
if ( StringUtils . isEmpty ( question . getAnalysis ( ) ) ) {
question . setAnalysis ( "<p> <span> 略 </span> <br> </p>" ) ;
}
// 设置题目答案, 直接从题目Vo对象中获取答案内容赋值给QuestionEntity对象的相应属性。
question . setAnswer ( questionVo . getAnswer ( ) ) ;
// 设置题目审核状态为“1”, 这里“1”所代表的具体审核状态含义需要结合整个业务逻辑来确定, 可能表示已审核通过或者其他特定的审核情况,
// 用于在数据库中记录题目在审核方面的状态信息。
question . setAuditStatus ( "1" ) ;
// 设置题目所属章节ID为空字符串, 这可能意味着当前题目暂时没有关联到具体的章节, 或者还未获取到章节相关信息, 具体要根据业务场景来判断。
question . setChapterId ( "" ) ;
// 设置题目所属课程ID, 将前面获取到的CourseEntity对象中的课程ID赋值给题目实体的相应属性, 确保题目与正确的课程建立关联关系。
question . setCourseId ( course . getId ( ) ) ;
// 设置题目所属试卷ID, 从前面获取的PaperEntity对象中获取试卷ID赋值给题目实体的相应属性, 以此建立题目与试卷之间的明确关联关系。
question . setPaperId ( paper . getId ( ) ) ;
// 设置题目类型, 把QuestionType枚举值转换为字符串( 通过getValue().toString()方法)后赋值给题目实体的相应属性,
// 这样在数据库中就能准确记录题目具体的类型信息了。
question . setQuestionType ( questionType . getValue ( ) . toString ( ) ) ;
// 判断题目Vo对象中的复习要点( questionVo.getReviewPoint())是否不为空且包含元素,如果满足条件,
// 则使用StringUtils的join方法将复习要点列表转换为以逗号分隔的字符串, 并赋值给题目实体的复习要点属性, 方便在数据库中以合适的格式存储该信息。
if ( questionVo . getReviewPoint ( ) ! = null & & questionVo . getReviewPoint ( ) . size ( ) > 0 ) {
question . setReviewPoint ( StringUtils . join ( questionVo . getReviewPoint ( ) . toArray ( ) , "," ) ) ;
}
// 处理题目分数相关信息, 如果题目Vo对象中的分数( questionVo.getScore())不为空,并且这个分数字符串中包含“分”字,
// 则将该分数内容赋值给题目实体的分数属性,用于准确记录题目对应的分值情况,符合常规的分数记录逻辑。
if ( questionVo . getScore ( ) ! = null ) {
if ( questionVo . getScore ( ) . contains ( "分" ) ) {
question . setScore ( questionVo . getScore ( ) ) ;
}
// 如果题目Vo对象中的分数字符串包含“年”字, 这里可能存在特殊的业务逻辑处理情况, 比如在特定场景下分数字段复用为年份相关信息的记录,
// 则将该分数内容赋值给题目实体的年份属性,具体含义需结合
if ( questionVo . getScore ( ) ! = null ) {
// 判断题目Vo对象中的分数( questionVo.getScore())字符串是否包含“年”字,如果包含,说明在当前业务逻辑下,
// 这个分数字段可能有特殊含义( 也许是复用该字段来表示年份相关信息) , 则将该分数内容赋值给题目实体的年份属性( question.setYear(questionVo.getScore()))。
if ( questionVo . getScore ( ) . contains ( "年" ) ) {
question . setYear ( questionVo . getScore ( ) ) ;
}
}
// 再次判断题目Vo对象中的年份( questionVo.getYear())是否不为空,进行这一步判断是为了确保年份信息的准确设置,
// 因为前面可能只是基于分数字段中是否包含“年”字来设置年份属性,这里再单独判断年份字段本身的情况。
if ( questionVo . getYear ( ) ! = null ) {
// 如果题目Vo对象中的年份字符串包含“年”字, 那么将该年份内容赋值给题目实体的年份属性, 以保证题目实体中年份信息的准确性和完整性,
// 符合业务逻辑中对题目年份信息记录的要求。
if ( questionVo . getYear ( ) . contains ( "年" ) ) {
question . setYear ( questionVo . getYear ( ) ) ;
}
}
// 设置题目所属学科ID, 将从前面通过subjectMapper查询获取到的SubjectEntity对象中的学科ID赋值给题目实体的相应属性( question.setSubjectId(subject.getId())) ,
// 以此明确题目所属的学科,建立题目与学科之间的正确关联关系,便于后续按照学科维度对题目进行分类、查询等业务操作。
question . setSubjectId ( subject . getId ( ) ) ;
// 判断题目Vo对象中的答案图片列表( questionVo.getAnswerImages())是否不为空且包含元素,即检查题目答案中是否存在图片,
// 如果存在图片, 则需要进行图片相关的处理操作, 包括图片下载以及在题目答案文本中替换图片URL等操作。
if ( questionVo . getAnswerImages ( ) ! = null & & questionVo . getAnswerImages ( ) . size ( ) > 0 ) {
// 将题目Vo对象中的答案图片列表转换为一个HashSet集合, 使用HashSet可以去除重复的图片URL, 确保每个图片只处理一次, 提高处理效率,
// 同时方便后续的遍历操作( 因为Set集合不允许重复元素, 符合处理图片URL这种唯一性要求较高的场景) 。
Set < String > imagesSet = new HashSet < String > ( questionVo . getAnswerImages ( ) ) ;
// 遍历答案图片集合, 对每一张图片进行下载以及URL替换操作。
for ( String img : imagesSet ) {
// 调用getFileName方法获取要下载的图片文件的文件名, 该方法会根据图片URL以及一些业务规则生成文件名,
// 文件名的生成逻辑在getFileName方法中具体实现( 通常会考虑文件后缀、编号等因素来保证文件名的唯一性和规范性) 。
String fileName = getFileName ( img ) ;
// 调用getFilePath方法获取文件存储的基础路径, 该路径是预先定义好的, 用于确定文件在服务器上大致的存储位置,
// 具体路径可能根据业务需求和项目配置来设定,比如按照课程、学科等分类来划分不同的文件存储目录。
String filePath = getFilePath ( ) ;
// 调用getFileDatePath方法获取基于当前日期时间生成的文件路径部分, 通常用于按照时间来进一步细分文件存储目录,
// 例如每天的文件存放在不同的以日期时间命名的子目录下,方便文件管理和查找,该方法内部会利用日期格式化来生成相应的路径字符串。
String fileDatePath = getFileDatePath ( ) ;
// 根据获取到的文件路径和日期路径创建一个File对象, 表示要创建的文件目录对象, 如果该目录不存在,
// 则通过mkdirs方法创建该目录及其所有必要的父目录, 确保文件下载时有对应的存储目录存在, 避免出现文件保存失败的情况。
File dir = new File ( filePath + fileDatePath + "/" ) ;
if ( ! dir . exists ( ) )
dir . mkdirs ( ) ;
// 使用FileUtil工具类的downFile方法下载图片文件, 传入图片URL、下载超时时间( 这里使用XxlCrawlerConf.TIMEOUT_MILLIS_DEFAULT作为默认超时时间) 、
// 文件存储路径以及文件名等参数,该方法内部实现了具体的文件下载逻辑,比如建立网络连接、读取文件流并保存到本地等操作,
// 并返回一个布尔值表示文件下载是否成功。
boolean ret = FileUtil . downFile ( img , XxlCrawlerConf . TIMEOUT_MILLIS_DEFAULT , filePath + fileDatePath + "/" , fileName ) ;
// 在控制台打印图片下载的结果信息, 方便在运行程序时查看图片下载情况, 便于调试和监控, 如果下载成功则显示“success”, 失败则显示“fail”,
// 同时打印出当前正在处理的图片URL, 让开发人员能清楚知道具体是哪张图片的下载情况。
System . out . println ( "down images " + ( ret ? "success" : "fail" ) + ": " + img ) ;
// 在题目答案文本中替换图片的原始URL为新的本地文件路径对应的URL, 通过调用String的replace方法, 将题目答案中原来的图片URL( img)
// 替换为新的格式(/files/paper/ + COURSE_ID + '/' + fileDatePath + "/" + fileName) , 使其指向下载到本地后的图片文件位置,
// 这样在后续展示题目答案等相关操作时,就能正确加载本地存储的图片了。
question . setAnswer ( question . getAnswer ( ) . replace ( img , "/files/paper/" + COURSE_ID + '/' + fileDatePath + "/" + fileName ) ) ;
}
// 将处理后的题目答案重新赋值给题目实体的答案属性,虽然看起来这一步有点多余(因为前面已经在原答案属性上进行了替换操作),
// 但从代码的清晰性和完整性角度考虑,这样做可以更明确地表示答案属性经过一系列处理后最终的赋值情况,避免其他地方误修改该属性导致逻辑混乱。
question . setAnswer ( question . getAnswer ( ) ) ;
}
// 判断题目Vo对象中的解析图片列表( questionVo.getAnalysisImages())是否不为空且包含元素,即检查题目解析内容中是否存在图片,
// 如果存在图片, 同样需要进行图片下载以及在题目解析文本中替换图片URL等相关操作, 流程与处理答案图片类似。
if ( questionVo . getAnalysisImages ( ) ! = null & & questionVo . getAnalysisImages ( ) . size ( ) > 0 ) {
Set < String > imagesSet = new HashSet < String > ( questionVo . getAnalysisImages ( ) ) ;
for ( String img : imagesSet ) {
String fileName = getFileName ( img ) ;
String filePath = getFilePath ( ) ;
String fileDatePath = getFileDatePath ( ) ;
File dir = new File ( filePath + fileDatePath + "/" ) ;
if ( ! dir . exists ( ) )
dir . mkdirs ( ) ;
boolean ret = FileUtil . downFile ( img , XxlCrawlerConf . TIMEOUT_MILLIS_DEFAULT , filePath + fileDatePath + "/" , fileName ) ;
System . out . println ( "down images " + ( ret ? "success" : "fail" ) + ": " + img ) ;
// 在题目解析文本中替换图片的原始URL为新的本地文件路径对应的URL, 使题目解析在展示时能正确加载本地存储的图片,
// 替换逻辑与答案图片的URL替换类似, 只是针对的是题目解析内容中的图片URL替换。
question . setAnalysis ( question . getAnalysis ( ) . replace ( img , "/files/paper/" + COURSE_ID + '/' + fileDatePath + "/" + fileName ) ) ;
}
// 同样将处理后的题目解析内容重新赋值给题目实体的解析属性,确保解析属性的最终状态符合预期,保证数据的准确性和完整性。
question . setAnalysis ( question . getAnalysis ( ) ) ;
}
// 判断题目Vo对象中的题目内容图片列表( questionVo.getContentImages())是否不为空且包含元素,即检查题目内容本身是否包含图片,
// 如果有图片, 也要进行相应的图片下载以及在题目内容文本中替换图片URL等操作, 操作流程与前面处理答案图片、解析图片基本一致。
if ( questionVo . getContentImages ( ) ! = null & & questionVo . getContentImages ( ) . size ( ) > 0 ) {
Set < String > imagesSet = new HashSet < String > ( questionVo . getContentImages ( ) ) ;
for ( String img : imagesSet ) {
String fileName = getFileName ( img ) ;
String filePath = getFilePath ( ) ;
String fileDatePath = getFileDatePath ( ) ;
File dir = new File ( filePath + fileDatePath + "/" ) ;
if ( ! dir . exists ( ) ) {
dir . mkdirs ( ) ;
}
boolean ret = FileUtil . downFile ( img , XxlCrawlerConf . TIMEOUT_MILLIS_DEFAULT , filePath + fileDatePath + "/" , fileName ) ;
System . out . println ( "down images " + ( ret ? "success" : "fail" ) + ": " + img ) ;
// 在题目内容文本中替换图片的原始URL为新的本地文件路径对应的URL, 使得题目内容在展示时能够正确加载本地存储的图片,
// 保证题目内容的完整性和正确性,符合业务逻辑中对题目包含图片情况的处理要求。
question . setContent ( question . getContent ( ) . replace ( img , "/files/paper/" + COURSE_ID + '/' + fileDatePath + "/" + fileName ) ) ;
}
// 将处理后的题目内容重新赋值给题目实体的内容属性,明确题目内容属性经过图片相关处理后的最终状态,便于后续将题目数据准确存储到数据库中。
question . setContent ( question . getContent ( ) ) ;
}
// 设置题目图片的来源类型为“baidu”, 这里明确表示题目中的图片是从百度相关来源获取的( 可能是从百度题库等地方爬取的) ,
// 具体的来源类型设定取决于业务需求和数据溯源的要求,方便后续对图片来源进行统计、分析等操作。
question . setSourceType ( "baidu" ) ;
// 设置题目图片的来源URL, 将当前爬取页面的URL( html.baseUri()) 赋值给题目实体的来源URL属性, 记录题目图片最初是从哪个页面获取的,
// 有助于数据溯源以及在需要查看原始图片来源等情况下使用,保证数据的完整性和可追溯性。
question . setSourceUrl ( html . baseUri ( ) ) ;
// 使用注入的questionMapper( QuestionMapper接口的实现类实例) 的insert方法, 将处理好的题目实体对象( question) 中的数据插入到数据库对应的题目表中,
// 实现题目数据的持久化存储,完成整个题目爬取、处理以及存储的流程,确保题目信息能够正确保存到数据库里,供后续业务使用。
questionMapper . insert ( question ) ;
// 定义一个名为getFileName的私有方法, 它接收一个表示图片URL的字符串参数img, 其作用是根据给定的图片URL生成一个合适的文件名,
// 这个文件名会用于后续图片文件的保存操作,确保文件名具有一定的规范性和唯一性(结合业务规则生成)。
public String getFileName ( String img ) {
// 通过调用getFileNo方法获取一个文件编号部分, 这个编号可能基于当前时间、业务标识等因素生成, 用于保证文件名的唯一性( 避免重复文件名冲突) ,
// 然后拼接上图片URL中提取出的文件后缀部分( 通过img.substring(img.lastIndexOf("."))获取, 即从图片URL中获取最后一个“.”之后的内容作为后缀),
// 最终组成完整的文件名并返回。
return getFileNo ( ) + img . substring ( img . lastIndexOf ( "." ) ) ;
}
// 定义一个私有的方法getFilePath, 用于获取文件存储的基础路径, 该路径是预先配置好的固定路径, 按照业务逻辑设定了文件大致的存储位置,
// 这里返回的路径表明文件将存储在“/home/webdata/files/paper/”目录下, 并结合课程ID( COURSE_ID, 当前固定为“shengwu”) 进一步明确子目录,
// 方便对不同课程的相关文件进行分类存储管理。
private String getFilePath ( ) {
return "/home/webdata/files/paper/" + COURSE_ID + "/" ;
}
// 定义一个私有的方法getFileDatePath, 其目的是根据当前日期时间生成一个用于文件路径的时间相关部分,
// 通过使用SimpleDateFormat按照“yyyyMMddHH”格式对当前日期时间进行格式化, 生成类似“2024121510”这样的字符串( 代表具体的年、月、日、小时) ,
// 以此作为文件存储路径的一部分,便于按照时间维度对文件进行分类存储,例如每天的文件存放在以当天日期时间命名的子目录下,方便后续查找和管理文件。
private String getFileDatePath ( ) {
SimpleDateFormat sdf = new SimpleDateFormat ( "yyyyMMddHH" ) ;
String format = sdf . format ( new Date ( ) ) ;
return format ;
}
// 定义一个私有的方法getFileNo, 用于生成文件编号, 该编号在整个文件管理逻辑中起到保证文件唯一性以及便于排序等作用( 具体取决于业务需求) 。
private String getFileNo ( ) {
// 首先使用SimpleDateFormat按照“yyyyMMddHH”格式获取当前日期时间的格式化字符串, 例如“2024121510”, 这一步和getFileDatePath方法中获取时间格式的逻辑类似,
// 是基于当前时间来生成编号的一部分,确保不同时间生成的编号有一定区分度,同时也便于后续按时间维度对文件进行管理和查找等操作。
SimpleDateFormat sdf = new SimpleDateFormat ( "yyyyMMddHH" ) ;
String format = sdf . format ( new Date ( ) ) ;
// 创建一个DecimalFormat对象, 使用之前定义的FILES_NO_FORMAT( 固定格式为“000000000”) 作为格式化模板,
// 目的是将后续生成的自增编号按照这个固定长度的数字格式进行格式化,保证编号长度统一且具有一定的顺序性和可读性。
DecimalFormat df = new DecimalFormat ( FILES_NO_FORMAT ) ;
// 构建一个用于在缓存中作为键的字符串, 它由FILES_PREFIX( 当前固定为“shengwu”) 和前面获取的日期时间格式化字符串( format) 拼接而成,
// 这个键用于在缓存服务( cacheService) 中标识一个特定的计数项, 方便后续基于业务场景对不同的文件编号进行区分和管理( 比如不同课程、不同业务模块等可以有不同的计数规则) 。
String key = FILES_PREFIX + format ;
// 调用cacheService( 注入的基于Redis的缓存服务类实例) 的incr方法, 对前面构建的键( key) 对应的值进行自增操作,
// Redis的自增操作是原子性的, 能保证在分布式环境下或者多线程环境中计数的准确性和一致性, 这里实现了文件编号的自增功能, 每次调用该方法都会使编号加1。
Long incr = cacheService . incr ( key ) ;
// 使用DecimalFormat对象将自增后的编号( incr) 按照之前定义的固定格式( FILES_NO_FORMAT) 进行格式化, 得到一个规范的文件编号字符串,
// 例如如果自增后编号为1, 格式化后可能就是“shengwu000000001”, 然后在前面再加上FILES_PREFIX( “shengwu”) , 最终返回完整的文件编号,
// 这个文件编号会作为文件名的一部分,用于保证文件名在整个业务场景下的唯一性和规范性。
String avatorNo = FILES_PREFIX + df . format ( incr ) ;
return avatorNo ;
}
// 通过调用build方法构建出前面配置好的XxlCrawler实例, 完成爬虫对象的创建, 这个爬虫实例已经按照之前设置的各项参数( 如页面加载策略、解析逻辑、线程数量等) 进行了配置,
// 后续可以使用这个实例来启动爬虫进行网页爬取以及相关的数据处理操作。
} ) . build ( ) ;
// 获取前面构建好的XxlCrawler实例中的运行数据对象( RunData) , 这个对象用于管理爬虫在运行过程中的各种相关数据,
// 例如已经访问过的URL、还需要访问的URL等信息, 在后续的爬取流程以及数据处理过程中会不断对这个对象中的数据进行更新和使用, 起到协调整个爬虫工作流程的作用。
runData = crawler . getRunData ( ) ;
// 初始化页码变量page为1, 表示从第一页开始进行分页查询操作, 这里的分页是针对要爬取的题目相关数据进行的, 目的是分批处理大量的题目数据, 便于管理和提高效率。
int page = 1 ;
// 设置每页的数据量为1000, 即每次查询数据库获取1000条题目相关的数据记录, 这个数量可以根据实际情况( 如数据库性能、业务需求等) 进行调整,
// 合理的每页数据量设置有助于平衡内存使用、查询效率以及数据处理的便捷性等方面的因素。
int pageSize = 1000 ;
// 开启一个无限循环,通过不断分页查询题目数据,直到满足特定条件(即查询到的数据量小于每页数据量,表示已经处理完所有数据)时才会跳出循环,
// 以此实现对所有相关题目数据的遍历处理,确保不遗漏任何需要处理的数据。
while ( true ) {
// 创建一个Page对象, 用于表示分页查询的相关参数, 传入当前页码( page) 和每页数据量( pageSize) ,
// 这个对象会被传递给后续的查询方法,以便数据库查询操作按照指定的分页规则获取相应的数据记录。
Page < CrawlerPaperEntity > questionPage = new Page < CrawlerPaperEntity > ( page , pageSize ) ;
// 使用注入的crawlerPaperMapper( CrawlerPaperMapper接口的实现类实例) 的selectPage方法进行分页查询操作,
// 传入前面构建的分页参数对象( questionPage) 以及查询条件( 通过Condition.create().orderAsc(Arrays.asList("paper_id", "queindex"))构建),
// 这里构建的查询条件表示按照“paper_id”和“queindex”字段升序排列进行查询, 获取符合条件的CrawlerPaperEntity类型的数据列表,
// 也就是获取到一批与爬取试卷题目相关的实体数据, 便于后续对这些题目数据进行进一步的处理( 如添加URL到待爬取列表等操作) 。
List < CrawlerPaperEntity > questionList = crawlerPaperMapper . selectPage ( questionPage , Condition . create ( ) . orderAsc ( Arrays . asList ( "paper_id" , "queindex" ) ) ) ;
// 遍历查询到的题目数据列表, 对于每一条题目数据记录( CrawlerPaperEntity对象) , 获取其对应的题目URL( 通过getQuestionUrl方法) ,
// 然后将这个URL添加到爬虫的运行数据对象( runData) 中的待访问URL列表中, 这样爬虫后续就会按照顺序去访问这些题目对应的页面, 进行数据爬取和处理操作。
for ( int i = 0 ; i < questionList . size ( ) ; i + + ) {
runData . addUrl ( questionList . get ( i ) . getQuestionUrl ( ) ) ;
}
// 页码自增1, 表示准备查询下一页的数据, 继续下一轮的分页查询和数据处理流程, 直到处理完所有满足条件的数据页为止。
page + + ;
// 判断当前查询到的数据列表的大小( questionList.size()) 是否小于每页数据量( 1000) , 如果小于, 表示已经查询完所有数据, 没有更多的数据页了,
// 此时跳出循环, 结束分页查询和数据添加URL的操作, 准备启动爬虫进行实际的爬取和后续处理工作。
if ( questionList . size ( ) < 1000 ) {
break ;
}
}
// 启动前面配置好的XxlCrawler实例, 传入参数true表示以某种特定的启动模式( 具体取决于XxlCrawler框架的内部实现逻辑) 启动爬虫,
// 启动后爬虫会按照预先设置的参数(如爬取范围、页面解析逻辑等)开始进行网页爬取、数据提取以及后续的数据处理和存储等操作,
// 整个流程围绕着获取试卷题目相关信息并进行处理后保存到数据库中展开, 同时涉及图片文件下载及相关URL替换等相关复杂操作( 在前面的代码逻辑中已定义) 。
// 获取科目(这里“获取科目”从代码逻辑上看不太明确具体含义,可能在爬虫启动后有进一步获取科目相关信息用于后续业务处理的逻辑,不过需要结合更多上下文代码来准确判断)。
crawler . start ( true ) ; }
}
}
// 获取科目
crawler . start ( true ) ;
}
}