共计 6005 个字符,预计需要花费 16 分钟才能阅读完成。
项目背景
小说网站优书网(http://yousuu.com/bookstore/)提供的小说查询功能不是很强大,很多高级查询功能都没有,比如想要查询出评分在 8.0 以上并且标签包含‘仙侠’、字数超过 100 万字的小说列表,查询结果按评分倒序排序。为了解决这个痛点,我们把所有小说数据(包含小说名称、评分、简介、作者等信息)爬到本地来,然后导入 elasticsearch 中,最后就可以构建出任何我们想要的查询了。
项目实战
实现分析
观察上面两张截图,可以看到列表页除了简介信息,其他信息都有;所以我们的实现思路就是从列表页提取出小说名称、作者、字数、评分、状态、标签等信息,从详情页提取出简介信息。
代码实现
通过文章《webmagic 核心设计和运行机制分析》,我们已经知道在使用 WebMagic 框架时,因为每个页面的不同,所以必须要我们自己定制处理逻辑的组件是 PageProcessor(解析 html,提取目标数据)和 Pipeline(持久化目标数据)。
1. Spider:程序入口,爬虫启动类
@Component
public class YousuuTask {
private static final String SITE_CODE = "yousuu";
private static final String URL = "http://www.yousuu.com/bookstore/?type&tag&countWord&status&update&sort&page=";
public void doTask() {MySpider mySpider = MySpider.create(new YousuuProcessor());
mySpider.setDownloader(new MyDownloader(SITE_CODE));
mySpider.setScheduler(new RedisScheduler(SITE_CODE));
mySpider.addPipeline(new YousuuPipeline());
mySpider.thread(10);
int totalPage = 8187;
// 添加起始 url
for(int i=1; i<=totalPage; i++) {Request request = new Request(URL + i);
// 在 Request 额外信息中设置页面类型
request.putExtra(YousuuProcessor.TYPE, YousuuProcessor.LIST_TYPE);
mySpider.addRequest(request);
}
mySpider.run();}
}
2. PageProcessor:解析 html,提取目标数据
public class YousuuProcessor implements PageProcessor {private Site site = Site.me().setRetryTimes(0).setSleepTime(2000).setTimeOut(60000);
public static final String TYPE = "type";
public static final String LIST_TYPE = "list";
public static final String DETL_TYPE = "detl";
@Override
public void process(Page page) {
// 从 Request 额外信息中取出页面类型,然后分别处理
String type = page.getRequest().getExtra(TYPE).toString();
switch (type) {
case LIST_TYPE:
processList(page);
break;
case DETL_TYPE:
processDetl(page);
break;
default:
break;
}
}
/**
* 处理列表页
* @param page
*/
private void processList(Page page) {Html html = page.getHtml();
List<Selectable> bookInfoNodes = html.xpath("//div[@class=\"book-info\"]").nodes();
List<Novel> novelList = new ArrayList<>();
for(Selectable node : bookInfoNodes) {String novelName = node.xpath("/div/a/text()").toString();
String novelUrl = node.xpath("/div/a/@href").toString();
String id = novelUrl.substring(novelUrl.lastIndexOf("/") + 1);
// 将详情页 url 添加到调度器
Request detlRequest = new Request("http://www.yousuu.com/book/" + id);
detlRequest.putExtra(TYPE, DETL_TYPE);
page.addTargetRequest(detlRequest);
// 子节点下标值从 1 开始
String author = node.xpath("/div/p[1]/router-link/text()").toString();
String wordNum = node.xpath("/div/p[1]/span[1]/text()").toString();
String lastUpdateTime = node.xpath("/div/p[1]/span[2]/text()").toString();
String status = node.xpath("/div/p[1]/span[3]/text()").toString();
String scoreStr = node.xpath("/div/p[2]/text()").toString();
scoreStr = scoreStr.substring("综合评分:".length());
String[] split = scoreStr.split("\\(");
Double score = Double.valueOf(split[0]);
String scorePersonNumStr = split[1].substring(0, split[1].length() - 2);
Integer scorePersonNum = Integer.valueOf(scorePersonNumStr);
List<Selectable> tagNodes = node.xpath("/div/p[4]/label").nodes();
StringBuffer tagBuff = new StringBuffer();
for(Selectable tagNode : tagNodes) {String tag = tagNode.xpath("/label/text()").toString();
tagBuff.append(tag + ",");
}
String tags = null;
if(tagBuff.length() > 0) {tags = tagBuff.substring(0, tagBuff.length()-1);
}
Novel novel = new Novel();
novel.setId(Long.valueOf(id));
novel.setName(novelName);
novel.setAuthor(author);
novel.setWordNum(NumberUtil.getDoubleNumber(wordNum));
novel.setLastUpdateTime(lastUpdateTime);
novel.setStatus(status);
novel.setScore(score);
novel.setScorePersonNum(scorePersonNum);
novel.setTags(tags);
novelList.add(novel);
}
page.putField("novelList", novelList);
}
/**
* 处理详情页
* @param page
*/
private void processDetl(Page page) {Html html = page.getHtml();
List<Selectable> nodes = html.xpath("//body/*[1]").nodes();
String script = nodes.get(1).toString();
int pos1 = script.indexOf("introduction");
int pos2 = script.indexOf("countWord");
String intro = script.substring(pos1+15, pos2-3);
String url = page.getRequest().getUrl();
String id = url.substring(url.lastIndexOf("/") + 1);
NovelDTO novelDTO = new NovelDTO(Long.valueOf(id), intro);
page.putField("novelDTO", novelDTO);
}
@Override
public Site getSite() {site.addHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3");
site.addHeader("Accept-Encoding", "gzip, deflate");
site.addHeader("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");
site.addHeader("Cache-Control", "max-age=0");
site.addHeader("Connection", "keep-alive");
site.addHeader("Cookie", "Hm_lvt_42e120beff2c918501a12c0d39a4e067=1566530194,1566819135,1566819342,1566963215; Hm_lpvt_42e120beff2c918501a12c0d39a4e067=1566963215");
site.addHeader("Host", "www.yousuu.com");
site.addHeader("Upgrade-Insecure-Requests", "1");
site.addHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.100 Safari/537.36");
return site;
}
}
说明:通过 Request 额外信息传递类型值 type,来区分从 Scheduler 中拉取出来的 URL 对应页面类型,然后分别解析处理。
3. Pipeline: 持久化目标数据
/**
* 持久化小说数据
*/
public class YousuuPipeline implements Pipeline {private NovelMapper novelMapper = SpringContextUtil.getBean(NovelMapper.class);
@Override
public void process(ResultItems resultItems, Task task) {
// 从列表页提取出除小说简介以外的所有信息,批量插入
Object novelListObj = resultItems.get("novelList");
if(null != novelListObj) {List<Novel> novelList = (List<Novel>) novelListObj;
if(CollectionUtils.isNotEmpty(novelList)) {novelMapper.batchInsert(novelList);
}
}
// 从详情页提取出小说简介信息,更新
Object novelDTOObj = resultItems.get("novelDTO");
if(null != novelDTOObj) {NovelDTO novelDTO = (NovelDTO) novelDTOObj;
Novel novel = new Novel();
BeanUtils.copyProperties(novelDTO, novel);
novelMapper.updateByPrimaryKeySelective(novel);
}
}
}
4. 实体类
@Data
public class Novel implements Serializable {
/**
* 小说 id, 自增
*/
@Id
private Long id;
/**
* 小说名称
*/
private String name;
/**
* 小说作者
*/
private String author;
/**
* 小说字数 (万字)
*/
private Double wordNum;
/**
* 小说状态
*/
private String status;
/**
* 小说评分
*/
private Double score;
/**
* 评分人数
*/
private Integer scorePersonNum;
/**
* 最后更新时间
*/
private String lastUpdateTime;
/**
* 小说标签,以逗号, 分割
*/
private String tags;
/**
* 小说简介
*/
private String intro;
}
5. 测试程序
@RunWith(SpringRunner.class)
@SpringBootTest
public class SpiderApplicationTests {
@Autowired
YousuuTask yousuuTask;
@Test
public void test() {yousuuTask.doTask();
}
}
运行结果
构建查询
最后将数据库表中保存的小说数据导入到 es 中(程序源码地址会在本文最后给出),编写 DSL 构建出我们需要的查询。
源代码地址
spider:https://github.com/xiawq87/sp…
es:https://github.com/xiawq87/no…
正文完