⚠注意: 可配置爬蟲現在僅在Python版本(v0.2.1-v0.2.4)可用,在最新版本Golang版本(v0.3.0)還暫時不可用,後續會加上,請關注近期更新
背景
實際的大型爬蟲開發項目中,爬蟲工程師會被要求抓取監控幾十上百個網站。一般來說這些網站的結構大同小異,不同的主要是被抓取項的提取規則。傳統方式是讓爬蟲工程師寫一個通用框架,然後將各網站的提取規則做成可配置的,然後將配置工作交給更初級的工程師或外包出去。這樣做將爬蟲開發流水線化,提高了部分生產效率。但是,配置的工作還是一個苦力活兒,還是非常消耗人力。因此,自動提取字段應運而生。
自動提取字段是Crawlab在版本v0.2.2中在可配置爬蟲基礎上開發的新功能。它讓用戶不用做任何繁瑣的提取規則配置,就可以自動提取出可能的要抓取的列表項,做到真正的“一鍵抓取”,順利的話,開發一個網站的爬蟲可以半分鐘內完成。市面上有利用機器學習的方法來實現自動抓取要提取的抓取規則,有一些可以做到精準提取,但遺憾的是平臺要收取高額的費用,個人開發者或小型公司一般承擔不起。
Crawlab的自動提取字段是根據人爲抓取的模式來模擬的,因此不用經過任何訓練就可以使用。而且,Crawlab的自動提取字段功能不會向用戶收取費用,因爲Crawlab本身就是免費的。
算法介紹
算法的核心來自於人的行爲本身,通過查找網頁中看起來像列表的元素來定位列表及抓取項。一般我們查找列表項是怎樣的一個過程呢?有人說:這還不容易嗎,一看就知道那個是各列表呀!兄弟,拜託... 咱們是在程序的角度談這個的,它只理解HTML、CSS、JS這些代碼,並不像你那樣智能。
我們識別一個列表,首先要看它是不是有很多類似的子項;其次,這些列表通常來說看起來比較“複雜”,含有很多看得見的元素;最後,我們還要關注分頁,分頁按鈕一般叫做“下一頁”、“下頁”、“Next”、“Next Page”等等。
用程序可以理解的語言,我們把以上規則總結如下:
列表項
- 從根節點自上而下遍歷標籤;
- 對於每一個標籤,如果包含多個同樣的子標籤,判斷爲列表標籤候選;
- 取子標籤(遞歸)個數最多的列表標籤候選爲列表標籤;
列表子項
- 對以上規則提取的列表標籤,對每個子標籤(遞歸)進行遍歷
- 將有href的a標籤爲加入目標字段;
- 將有text的標籤爲加入目標字段。
分頁
- 對於每一個標籤,如果標籤文本爲特定文本(“下一頁”、“下頁”、“next page”、“next”),選取該標籤爲目標標籤。
這樣,我們就設計好了自動提取列表項、列表子項、分頁的規則。剩下的就是寫代碼了。我知道這樣的設計過於簡單,也過於理想,沒有考慮到一些特殊情況。後面我們將通過在一些知名網站上測試看看我們的算法表現如何。
算法實現
算法實現很簡單。爲了更好的操作HTML標籤,我們選擇了lxml
庫作爲HTML的操作庫。lxml
是python的一個解析庫,支持HTML和XML的解析,支持XPath、CSS解析方式,而且解析效率非常高。
自上而下的遍歷語法是sel.iter()
。sel
是etree.Element
,而iter
會從根節點自上而下遍歷各個元素,直到遍歷完所有元素。它是一個generator
。
構造解析樹
在獲取到頁面的HTML之後,我們需要調用lxml
中的etree.HTML
方法構造解析樹。代碼很簡單如下,其中r
爲requests.get
的Response
# get html parse tree
sel = etree.HTML(r.content)
這段帶代碼在SpiderApi._get_html
方法裏。源碼請見這裏。
輔助函數
在開始構建算法之前,我們需要實現一些輔助函數。所有函數是封裝在SpiderApi
類中的,所以寫法與類方法一樣。
@staticmethod
def _get_children(sel):
# 獲取所有不包含comments的子節點
return [tag for tag in sel.getchildren() if type(tag) != etree._Comment]
@staticmethod
def _get_text_child_tags(sel):
# 遞歸獲取所有文本子節點(根節點)
tags = []
for tag in sel.iter():
if type(tag) != etree._Comment and tag.text is not None and tag.text.strip() != '':
tags.append(tag)
return tags
@staticmethod
def _get_a_child_tags(sel):
# 遞歸獲取所有超鏈接子節點(根節點)
tags = []
for tag in sel.iter():
if tag.tag == 'a':
if tag.get('href') is not None and not tag.get('href').startswith('#') and not tag.get(
'href').startswith('javascript'):
tags.append(tag)
return tags
獲取列表項
下面是核心中的核心!同學們請集中注意力。
我們來編寫獲取列表項的代碼。以下是獲得列表標籤候選列表list_tag_list
的代碼。看起來稍稍有些複雜,但其實邏輯很簡單:對於每一個節點,我們獲得所有子節點(一級),過濾出高於閾值(默認10)的節點,然後過濾出節點的子標籤類別唯一的節點。這樣候選列表就得到了。
list_tag_list = []
threshold = spider.get('item_threshold') or 10
# iterate all child nodes in a top-down direction
for tag in sel.iter():
# get child tags
child_tags = self._get_children(tag)
if len(child_tags) < threshold:
# if number of child tags is below threshold, skip
continue
else:
# have one or more child tags
child_tags_set = set(map(lambda x: x.tag, child_tags))
# if there are more than 1 tag names, skip
if len(child_tags_set) > 1:
continue
# add as list tag
list_tag_list.append(tag)
接下來我們將從候選列表中篩選出包含最多文本子節點的節點。聽起來有些拗口,打個比方:一個電商網站的列表子項,也就是產品項,一定是有許多例如價格、產品名、賣家等信息的,因此會包含很多文本節點。我們就是通過這種方式過濾掉文本信息不多的列表(例如菜單列表、類別列表等等),得到最終的列表。在代碼裏我們存爲max_tag
。
# find the list tag with the most child text tags
max_tag = None
max_num = 0
for tag in list_tag_list:
_child_text_tags = self._get_text_child_tags(self._get_children(tag)[0])
if len(_child_text_tags) > max_num:
max_tag = tag
max_num = len(_child_text_tags)
下面,我們將生成列表項的CSS選擇器。以下代碼實現的邏輯主要就是根據上面得到的目標標籤根據其id
或class
屬性來生成CSS選擇器。
# get list item selector
item_selector = None
if max_tag.get('id') is not None:
item_selector = f'#{max_tag.get("id")} > {self._get_children(max_tag)[0].tag}'
elif max_tag.get('class') is not None:
cls_str = '.'.join([x for x in max_tag.get("class").split(' ') if x != ''])
if len(sel.cssselect(f'.{cls_str}')) == 1:
item_selector = f'.{cls_str} > {self._get_children(max_tag)[0].tag}'
找到目標列表項之後,我們需要做的就是將它下面的文本標籤和超鏈接標籤提取出來。代碼如下,就不細講了。感興趣的讀者可以看源碼來理解。
# get list fields
fields = []
if item_selector is not None:
first_tag = self._get_children(max_tag)[0]
for i, tag in enumerate(self._get_text_child_tags(first_tag)):
if len(first_tag.cssselect(f'{tag.tag}')) == 1:
fields.append({
'name': f'field{i + 1}',
'type': 'css',
'extract_type': 'text',
'query': f'{tag.tag}',
})
elif tag.get('class') is not None:
cls_str = '.'.join([x for x in tag.get("class").split(' ') if x != ''])
if len(tag.cssselect(f'{tag.tag}.{cls_str}')) == 1:
fields.append({
'name': f'field{i + 1}',
'type': 'css',
'extract_type': 'text',
'query': f'{tag.tag}.{cls_str}',
})
for i, tag in enumerate(self._get_a_child_tags(self._get_children(max_tag)[0])):
# if the tag is <a...></a>, extract its href
if tag.get('class') is not None:
cls_str = '.'.join([x for x in tag.get("class").split(' ') if x != ''])
fields.append({
'name': f'field{i + 1}_url',
'type': 'css',
'extract_type': 'attribute',
'attribute': 'href',
'query': f'{tag.tag}.{cls_str}',
})
分頁的代碼很簡單,實現也很容易,就不多說了,大家感興趣的可以看源碼
這樣我們就實現了提取列表項以及列表子項的算法。
使用方法
要使用自動提取字段,首先得安裝Crawlab。如何安裝請查看Github。
Crawlab安裝完畢運行起來後,得創建一個可配置爬蟲,詳細步驟請參考[[爬蟲手記] 我是如何在3分鐘內開發完一個爬蟲的
](https://juejin.im/post/5ceb43...。
創建完畢後,我們來到創建好的可配置爬蟲的爬蟲詳情的配置標籤,輸入開始URL,點擊提取字段按鈕,Crawlab將從開始URL中提取列表字段。
接下來,點擊預覽看看這些字段是否爲有效字段,可以適當增刪改。可以的話點擊運行,爬蟲就開始爬數據了。
好了,你需要做的就是這幾步,其餘的交給Crawlab來做就可以了。
測試結果
本文在對排名前10的電商網站上進行了測試,僅有3個網站不能識別(分別是因爲“動態內容”、“列表沒有id/class”、“lxml定位元素問題”),成功率爲70%。讀者們可以嘗試用Crawlab自動提取字段功能對你們自己感興趣的網站進行測試,看看是否符合預期。結果的詳細列表如下。
網站 | 成功提取 | 原因 |
---|---|---|
淘寶 | N | 動態內容 |
京東 | Y | |
阿里巴巴1688 | Y | |
搜了網 | Y | |
蘇寧易購 | Y | |
糯米網 | Y | |
買購網 | N | 列表沒有id/class |
天貓 | Y | |
噹噹網 | N | lxml定位元素問題 |
Crawlab的算法當然還需要改進,例如考慮動態內容和列表沒有id/class等定位點的時候。也歡迎各位前來試用,甚至貢獻該項目。
Github: tikazyq/crawlab
如果您覺得Crawlab對您的日常開發或公司有幫助,請加作者微信拉入開發交流羣,大家一起交流關於Crawlab的使用和開發。
<p align="center">
<img src="https://user-gold-cdn.xitu.io/2019/3/15/169814cbd5e600e9?w=674&h=896&f=jpeg&s=132795" height="480">
</p
本篇文章由一文多發平臺ArtiPub自動發佈