最近由於畢設一定的數據源,故需要進行爬蟲方面的開發,網上的爬蟲框架很多,包括scrapy(基於python),PySpider(基於python),webMagic(基於Java)等等。在網上查找了一番資料後選定webMagic,一方面它可以基於Java進行爬蟲的開發,更重要的還是它的學習成本很低,官方文檔簡單易懂(國人開發,中文文檔)。作者提供了一組高效而簡潔的api,使得我們能用少量的代碼就能實現爬蟲的開發。
什麼是webMagic?
webmagic的是一個無須配置、便於二次開發的爬蟲框架,它提供簡單靈活的API,只需少量代碼即可實現一個爬蟲。
作者的說法:
WebMagic是一個簡單靈活的Java爬蟲框架。基於WebMagic,你可以快速開發出一個高效、易維護的爬蟲。
特性:
- 簡單的API,可快速上手
- 模塊化的結構,可輕鬆擴展
- 提供多線程和分佈式支持
webMagic組件結構
主要有四個組件:Downloader,PageProcessor,Pipeline,Scheduler。通過Spider則將這幾個組件組織起來,讓它們可以互相交互,流程化的執行,可以認爲Spider是一個大的容器,它也是WebMagic邏輯的核心。
附官方提供的webMagic結構圖:
1.Downloader
Downloader負責從互聯網上下載頁面,以便後續處理。WebMagic默認使用了Apache HttpClient作爲下載工具。
2.PageProcessor
PageProcessor負責解析頁面,抽取有用信息,以及發現新的鏈接。WebMagic使用Jsoup作爲HTML解析工具,並基於其開發瞭解析XPath的工具Xsoup。
在這四個組件中,PageProcessor
對於每個站點每個頁面都不一樣,是需要使用者定製的部分。
3.Scheduler
Scheduler負責管理待抓取的URL,以及一些去重的工作。WebMagic默認提供了JDK的內存隊列來管理URL,並用集合來進行去重。也支持使用Redis進行分佈式管理。
除非項目有一些特殊的分佈式需求,否則無需自己定製Scheduler。
4.Pipeline
Pipeline負責抽取結果的處理,包括計算、持久化到文件、數據庫等。WebMagic默認提供了“輸出到控制檯”和“保存到文件”兩種結果處理方案。
Pipeline
定義了結果保存的方式,如果你要保存到指定數據庫,則需要編寫對應的Pipeline。對於一類需求一般只需編寫一個Pipeline
。
基於webMagic進行爬蟲開發
本次實踐案例是爬取馬蜂窩的熱門旅遊城市及對應城市下的所有旅遊景點信息
不得不說馬蜂窩旅遊網的UI設計還是蠻讚的,相對於其他旅遊網站很清新簡潔,首頁的大輪播圖還提供了一種強烈的視覺衝擊,給人很舒服的觀感。
1.爬蟲開發的步驟:
- 數據爬取:實現PageProcessor(PageProcessor的定製)
- 爬蟲的配置
- 頁面元素的抽取
- 鏈接的發現
- 數據持久化:使用Pipeline保存結果(定製Pipeline)
- 保存結果到文件、數據庫等一系列功能
- 數據整理(利用SQL腳本將數據進行規範整理)
2. 爬取目標信息
1. 鏈接發現
爬取馬蜂窩旅遊網熱門旅遊城市及該城市的介紹信息(暫定國內);
爬取該城市下的所有旅遊景點詳細信息;
2. 在頁面打開鏈接:https://www.mafengwo.cn/mdd/,按f12,可以看到,每個城市對應的詳情鏈接大致一樣"/travel-scenic-spot/mafengwo/10065.html",只有在.html前面的數字串不一樣,這應該是馬蜂窩網內部定義的城市編號信息,用於作爲不同城市的標識。
3. 點開一個北京鏈接的頁面,可以發現本頁並沒有關於北京市的詳細介紹,其實具體介紹在深入另一個頁面,即下圖 "景點",是一個新的鏈接“/jd/10065/gonglve.html”,同樣帶了一個標識城市的數字串,與上面的是一致的10065。點擊進去。
4. 是的,我們要的信息找到了,城市名,城市介紹,圖片等信息(該城市下的圖片在本頁有,不做過多截圖)
5. 在上面當前頁面上,會有該城市下的所有旅遊景點信息,這就是我們第二部分要爬取的內容,頁面鏈接組成是“/poi/3474.html”,跟上面同樣的套路,用數字串作爲該景點的標識。點擊進去。
6. 看了下,大致排版相對固定,景點名,一大圖兩小圖,景點詳細介紹,這大概就是我們要爬取的數據。
7. 大概數據查找過程如上,接下來的工作便是編寫爬蟲邏輯。
b.編寫爬蟲邏輯 pageProcessor
1. 建立Java工程,由於本次開發是在畢設springboot工程的基礎上進行的,故使用了一些spring相關的註解來配合其他功能的實現,但這完全不影響爬蟲模塊的編寫,跟普通的Java工程實現是一致的。
實現方式:
- 實現pageProcessor接口
- 設置爬取站點信息
- 實現process方法(爬取熱門城市頁,熱門城市鏈接,該城市下所有景點信息)
核心process邏輯大致如下:
- 匹配當前頁(即a-1步驟那個圖),則執行doCityListProcess(Page page)方法
- 匹配城市頁(即a-4步驟那個圖),則執行doCityProcess(Page page)方法
- 匹配景點頁(即a-5步驟那個圖),則執行doScenicProcess(Page page)方法
/**
* 爬取數據PageProcessor (城市列表,各城市下的所有景點)
*
* @author [email protected]
* 2019-02-19 15:05
* @version 1.0.0
*/
@Component
@Slf4j
public class PenguinPageProcessor implements PageProcessor {
private Site site = Site
.me()
.setDomain(SpiderConstant.DOMAIN)
.setSleepTime(SpiderConstant.SPIDER_SLEEP_TIME)
.setUserAgent(SpiderConstant.BROWSER_USER_AGENT);
@Override
public void process(Page page) {
try {
if (page.getUrl().regex(SpiderConstant.URL_CITY_LIST).match()) {
this.doCityListProcess(page);
}
if (page.getUrl().regex(SpiderConstant.URL_CITY).match()) {
this.doCityProcess(page);
}
if (page.getUrl().regex(SpiderConstant.URL_SCENIC).match()) {
this.doScenicProcess(page);
}
} catch (Exception e) {
log.info("【爬蟲爬取數據異常");
e.printStackTrace();
}
}
@Override
public Site getSite() {
return site;
}
2.爬取當前所有城市名(圖a-1)
借用xpath解析器和強大的正則匹配,對頁面需要抽取的信息進行提取,並在新發現鏈接後通過page.addTargetRequests(List list);將新鏈接加入到待爬取的目標鏈接中去(存儲所有爬取鏈接的是List結構,FIFO)
private void doCityListProcess(Page page) throws Exception{
Thread.sleep(SpiderConstant.SPIDER_SLEEP_TIME);
List<String> cityListPageRequest = page.getHtml()
.xpath("div[@class=\"hot-list clearfix\"]")
.links().regex("\\d+").all();
List<String> citysPageRequest = cityListPageRequest.stream()
.map(url -> "/jd/" + url + "/gonglve.html")
.distinct()
.collect(Collectors.toList());
page.addTargetRequests(citysPageRequest);
log.info("【爬取城市列表鏈接信息】: {}", citysPageRequest);
}
3.爬取當前城市的詳情信息(圖a-4)
這裏的邏輯相對上面多一點,主要是除了爬取當前城市信息外,還要爬取當前城市下的所有景點鏈接
private void doCityProcess(Page page) throws Exception{
Thread.sleep(SpiderConstant.SPIDER_SLEEP_TIME);
page.putField("pageType", SpiderEnum.CITY_PAGE.getCode());
page.putField("cityName", page.getHtml()
.xpath("//div[@class='crumb']//div[@class='drop']//span[@class='hd']//a//text()")
.all()
.get(SpiderConstant.CITY_INDEX));
page.putField("introduce", page.getHtml()
.xpath("//div[@class='wrapper']//span[@id='mdd_poi_desc']//text()"));
if (page.getResultItems().get("introduce") == null) {
page.setSkip(true);
}
page.putField("cityPic", page.getHtml()
.xpath("//div[@class='large']//img/@src")
.all());
page.putField("headRate", page.getHtml()
.xpath("//span[@class='rev-total']//em/text()")
.all());
List<String> scenicListUrls = page.getHtml()
.xpath("//div[@class='wrapper']")
.links()
.regex("/poi/\\d+\\.html").all();
page.addTargetRequests(scenicListUrls
.stream()
.distinct()
.collect(Collectors.toList()));
log.info("【爬取城市詳情信息】: {}", page.getResultItems());
}
4.爬取景點的詳細信息(圖a-6)
private void doScenicProcess(Page page) throws Exception{
Thread.sleep(SpiderConstant.SPIDER_SLEEP_TIME);
page.putField("pageType", SpiderEnum.SCENIC_PAGE.getCode());
page.putField("cityName", page.getHtml()
.xpath("//div[@class='crumb']//div[@class='drop']//span[@class='hd']//a//text()")
.all()
.get(SpiderConstant.SCENIC_CITY_INDEX));
page.putField("scenicName", page.getHtml()
.xpath("//div[@class='title']//h1/text()"));
page.putField("scenicPic", page.getHtml()
.xpath("//div[@class='bd']//img/@src")
.all());
page.putField("introduce", page.getHtml()
.xpath("//div[@class='summary']/text()"));
page.putField("headRate", page.getHtml()
.xpath("//li[@data-scroll='commentlist']//span/text()")
.regex("\\d+"));
log.info("【爬取景點詳情信息】: {}", page.getResultItems());
}
5.注意點
可能你也注意到了,在每個爬取方法開始前都會執行 Thread.sleep(SpiderConstant.SPIDER_SLEEP_TIME); 這是統一設置的爬取時間間隔,非常必要(有錢租代理IP池的請忽略),要是對爬取速度不加以限制,對方的反爬機制就會認定你是爬蟲而不是人(沒有一個人(IP)能夠在一個或幾個頁面一秒內點好幾百次以上吧。。。),則會將你當前的ip拉黑導致你無法訪問和爬取數據。
c. 數據持久化 Pipeline
1. 在爬取完數據後,要對數據進行持久化操作(不然你爬它幹嘛==)
- 實現Pipeline接口
- 實現process方法獲取結果集resultItems
- 調用服務進行持久化(可以是原生實現也可以結合框架實現)
看下來其實跟pageProcessor的步驟差不多。
這裏通過一個pageType標識來區分不同信息(城市信息,景點信息)的保存
/**
* 爬蟲數據持久化服務Pipeline
*
* @author [email protected]
* 2019-02-19 15:07
* @version 1.0.0
*/
@Component
@Slf4j
public class PenguinPipeline implements Pipeline {
private PipelineService pipelineService = (PipelineService) SpringUtil.getBean(PipelineService.class);
@Override
public void process(ResultItems resultItems, Task task) {
Map<String, Object> mapResults = resultItems.getAll();
Iterator<Map.Entry<String, Object>> iter = mapResults.entrySet().iterator();
Map.Entry<String, Object> entry;
System.out.println("======================PenguinPipeline started!======================");
while (iter.hasNext()) {
entry = iter.next();
System.out.println(entry.getKey() + ":" + entry.getValue());
}
if (mapResults != null && mapResults.size() != SpiderConstant.ZERO) {
if (mapResults.get("pageType").equals(SpiderEnum.CITY_PAGE.getCode())) {
this.doCityPipeline(mapResults);
}
if (mapResults.get("pageType").equals(SpiderEnum.SCENIC_PAGE.getCode())) {
this.doScenicPipeline(mapResults);
}
}
System.out.println("======================PenguinPipeline ended!======================");
}
2.城市信息持久化 doCityPipeline
這裏涉及了城市熱度(歡迎程度,假定以城市所有景點的評論總量)的計算(業務邏輯需要,可忽略~)。
private void doCityPipeline(Map<String, Object> mapResults) {
City city = new City();
String headRatesStr = Arrays.asList(mapResults.get("headRate")).get(SpiderConstant.ZERO).toString();
city.builder()
.cityName(Optional.ofNullable(mapResults.get("cityName")).orElse("").toString())
.introduce(Optional.ofNullable(mapResults.get("introduce")).orElse("").toString())
.cityPic(Optional.ofNullable(mapResults.get("cityPic")).orElse("").toString())
.headRate((int)Arrays.stream(headRatesStr.substring(1, headRatesStr.length() - 1)
.split(","))
.mapToDouble(eachHeadRate -> Double.parseDouble(eachHeadRate))
.sum())
.status(SpiderEnum.NORMAL_STATUS.getCode())
.build();
if (city != null) {
if (city.getIntroduce() != null) {
city.setCityPic(Optional.ofNullable(city.getCityPic()).orElse(SpiderConstant.NULL_PIC)
.substring(1, city.getCityPic().length() - 1));
pipelineService.insertIntoCity(city);
log.info("【城市信息持久化】: {}",city);
}
}
}
3.景點信息持久化
private void doScenicPipeline(Map<String, Object> mapResults) {
Scenic scenic = new Scenic();
scenic.setCityId(
pipelineService.selectCityIdByCityName(
Optional.ofNullable(mapResults.get("cityName"))
.orElse(SpiderConstant.NO_BELONG_CITY)
.toString()));
String scenicPicStr = Optional.ofNullable(mapResults.get("scenicPic")).orElse(SpiderConstant.NULL_PIC).toString();
scenic.builder()
.scenicName(Optional.ofNullable(mapResults.get("scenicName")).orElse("").toString())
.scenicPic(scenicPicStr.substring(1, scenicPicStr.length() - 1))
.introduce(Optional.ofNullable(mapResults.get("introduce")).orElse("").toString())
.headRate(Integer.parseInt(
Optional.ofNullable(mapResults.get("headRate"))
.orElse(SpiderConstant.ZERO)
.toString()))
.status(new Byte(SpiderConstant.ZERO.toString()))
.build();
pipelineService.insertIntoScenic(scenic);
log.info("【景點信息持久化】: {}",scenic);
}
d. 爬蟲啓動
1.本次開發是在畢設springboot工程的基礎上,故還是採用了springMVC的方式來進行爬蟲的啓動。在項目啓動後通過postman發起請求進行觸發。數據持久化服務這裏通過spring bean的方式提供,若採用普通Java類進行爬蟲的啓動,會導致服務無法初始化,調用拋出空指針異常,故需要通過springUtils輔助我們進行服務的初始化(這方面資料網上很多,當然這是題外話了~)
/**
* 爬蟲啓動
*
* @author [email protected]
* 2019-02-20 15:16
* @version 1.0.0
*/
@RestController
@RequestMapping("/spider")
public class SpiderController {
private static final String SPIDER_URL = "https://www.mafengwo.cn/mdd/";
@RequestMapping("/start")
public void spiderStart() {
Spider.create(new PenguinPageProcessor())
.addUrl(SPIDER_URL)
.addPipeline(new PenguinPipeline())
.run();
}
}
2. 爬蟲的啓動很簡單,通過Spider提供的靜態方法create(),指定PenguinPageProcessor和PenguinPipeline即可,這裏也可以採用多線程啓動加快爬取速度(當然這裏擔心IP被拉黑並沒有這麼做)
e. 爬取結果
本次共爬取了5000+條數據
f. 數據清洗
在我們對爬蟲數據進行數據庫存儲後,可能有些信息並不合我們所預想的,這時就需要通過SQL腳本來對數據進行一定對整理。
本次數據遇到的問題有:
1.城市id與我數據庫字典表中定義的不一致(這是肯定的,不同人有不同人自定義的值,當然也有幾個大致的版本)
2.城市所在省份信息沒有填充到城市表
<update id="updateProvinceByCityId">
update city set province=#{province} where city_id=#{cityId}
</update>
<update id="updateCityIdById">
update city set city_id=#{cityId} where id=#{id}
</update>
具體情況要靠業務結合去編寫,此處不過多贅述。
/**
* 數據信息修復服務
*
* @author [email protected]
* 2019-02-21 14:56
* @version 1.0.0
*/
@Service
public class DataServiceImpl implements DataService {
@Autowired
private DictionaryMapper dictionaryMapper;
@Autowired
private CityMapper cityMapper;
@Override
public void updateProvinceByCityName() {
List<City> cities = cityMapper.selectAll();
cities.stream().forEach(city -> {
cityMapper.updateProvinceByCityId(city.getCityId(),
dictionaryMapper.selectProvinceBycityName(city.getCityName()));
});
System.out.println("======Run finished=====");
}
@Override
public void updateCityIdByCityName() {
List<City> cities = cityMapper.selectAll();
cities.stream().forEach(city -> {
cityMapper.updateCityIdById(city.getId(),
dictionaryMapper.selectCityIdByCityName(city.getCityName()));
});
}
}
g. 結果集
1.城市表:
2.景點表:
h. 附webMagic官方文檔:
官方教程,還是很有必要看一下的~