用python3教你任意Html主內容提取

騰訊DeepOcean發表於2018-11-05

騰訊DeepOcean原創文章:dopro.io/pymainhtml.…

筆者上一篇文章我們講到了如何“從零開始造一個智障機器人”,如果感興趣的朋友可以看一看。本文可以說是上一篇文章的前傳,為造聊天機器人而打基礎。

篇文章中的對話機器人,其一問一答還是挺流暢的,那麼我們不禁思考,為什麼“機器人”能根據我們的問句,回答出符合邏輯、語義的答案呢?其實這一切的一切都是源於自然語言訓練的基礎——語料。

本文將和大家分享一些從網際網路上爬取語料的經驗。

0x1 工具準備

工欲善其事必先利其器,爬取語料的根基便是基於python。

我們基於python3進行開發,主要使用以下幾個模組:requests、lxml、json。

簡單介紹一個各模組的功能

01|requests

requests是一個Python第三方庫,處理URL資源特別方便。它的官方文件上寫著大大口號:HTTP for Humans(為人類使用HTTP而生)。相比python自帶的urllib使用體驗,筆者認為requests的使用體驗比urllib高了一個數量級。

我們簡單的比較一下:

urllib:

 1import urllib2
 2import urllib
 3
 4URL_GET = "https://api.douban.com/v2/event/list"
 5#構建請求引數
 6params = urllib.urlencode({'loc':'108288','day_type':'weekend','type':'exhibition'})
 7
 8#傳送請求
 9response = urllib2.urlopen('?'.join([URL_GET,'%s'])%params)
10#Response Headers
11print(response.info())
12#Response Code
13print(response.getcode())
14#Response Body
15print(response.read())
複製程式碼

requests:

 1import requests
 2
 3URL_GET = "https://api.douban.com/v2/event/list"
 4#構建請求引數
 5params = {'loc':'108288','day_type':'weekend','type':'exhibition'}
 6
 7#傳送請求
 8response = requests.get(URL_GET,params=params)
 9#Response Headers
10print(response.headers)
11#Response Code
12print(response.status_code)
13#Response Body
14print(response.text)複製程式碼

我們可以發現,這兩種庫還是有一些區別的:

1. 引數的構建:urllib需要對引數進行urlencode編碼處理,比較麻煩;requests無需額外編碼處理,十分簡潔。

2. 請求傳送:urllib需要額外對url引數進行構造,變為符合要求的形式;requests則簡明很多,直接get對應連結與引數。

3. 連線方式:看一下返回資料的頭資訊的“connection”,使用urllib庫時,"connection":"close",說明每次請求結束關掉socket通道,而使用requests庫使用了urllib3,多次請求重複使用一個socket,"connection":"keep-alive",說明多次請求使用一個連線,消耗更少的資源

4. 編碼方式:requests庫的編碼方式Accept-Encoding更全,在此不做舉例

綜上所訴,使用requests更為簡明、易懂,極大的方便我們開發。

02|lxml

BeautifulSoup是一個庫,而XPath是一種技術,python中最常用的XPath庫是lxml。

當我們拿到requests返回的頁面後,我們怎麼拿到想要的資料呢?這個時候祭出lxml這強大的HTML/XML解析工具。python從不缺解析庫,那麼我們為什麼要在眾多庫裡選擇lxml呢?我們選擇另一款出名的HTML解析庫BeautifulSoup來進行對比。

我們簡單的比較一下:

BeautifulSoup:

1from bs4 import BeautifulSoup  #匯入庫
2# 假設html是需要被解析的html
3
4#將html傳入BeautifulSoup 的構造方法,得到一個文件的物件
5soup = BeautifulSoup(html,'html.parser',from_encoding='utf-8')
6#查詢所有的h4標籤 
7links = soup.find_all("h4")
複製程式碼

lxml:

1from lxml import etree
2# 假設html是需要被解析的html
3
4#將html傳入etree 的構造方法,得到一個文件的物件
5root = etree.HTML(html)
6#查詢所有的h4標籤 
7links = root.xpath("//h4")
複製程式碼
 

我們可以發現,這兩種庫還是有一些區別的:

1. 解析html: BeautifulSoup的解析方式和JQ的寫法類似,API非常人性化,支援css選擇器;lxml的語法有一定的學習成本

2. 效能:BeautifulSoup是基於DOM的,會載入整個文件,解析整個DOM樹,因此時間和記憶體開銷都會大很多;而lxml只會區域性遍歷,另外lxml是用c寫的,而BeautifulSoup是用python寫的,明顯的效能上lxml>>BeautifulSoup。

綜上所訴,使用BeautifulSoup更為簡明、易用,lxml雖然有一定學習成本,但總體也很簡明易懂,最重要的是它基於C編寫,速度快很多,對於筆者這種強迫症,自然而然就選lxml啦。

03|json

python自帶json庫,對於基礎的json的處理,自帶庫完全足夠。但是如果你想更偷懶,可以使用第三方json庫,常見的有demjson、simplejson。

這兩種庫,無論是import模組速度,還是編碼、解碼速度,都是simplejson更勝一籌,再加上相容性simplejson更好。所以大家如果想使用方庫,可以使用simplejson。

0x2 確定語料源

將武器準備好之後,接下來就需要確定爬取方向。

以電競類語料為例,現在我們要爬電競類相關語料。大家熟悉的電競平臺有企鵝電競、企鵝電競和企鵝電競(斜眼),所以我們以企鵝電競上直播的遊戲作為資料來源進行爬取。

我們登陸企鵝電競官網,進入遊戲列表頁,可以發現頁面上有很多遊戲,通過人工去寫這些遊戲名收益明顯不高,於是我們就開始我們爬蟲的第一步:遊戲列表爬取。

任意html主內容提取

 1import requests
 2from lxml import etree
 3
 4# 更新遊戲列表
 5def _updateGameList():
 6    # 傳送HTTP請求時的HEAD資訊,用於偽裝為瀏覽器
 7    heads = {  
 8        'Connection''Keep-Alive',
 9        'Accept''text/html, application/xhtml+xml, */*',
10        'Accept-Language''en-US,en;q=0.8,zh-Hans-CN;q=0.5,zh-Hans;q=0.3',
11        'Accept-Encoding''gzip, deflate',
12        'User-Agent''Mozilla/6.1 (Windows NT 6.3; WOW64; Trident/7.0; rv:11.0) like Gecko'
13    }
14    # 需要爬取的遊戲列表頁
15    url = 'https://egame.qq.com/gamelist'
16
17    # 不壓縮html,最大連結時間為10妙
18    res = requests.get(url, headers=heads, verify=False, timeout=10)
19    # 為防止出錯,編碼utf-8
20    res.encoding = 'utf-8'
21    # 將html構建為Xpath模式
22    root = etree.HTML(res.content)
23    # 使用Xpath語法,獲取遊戲名
24    gameList = root.xpath("//ul[@class='livelist-mod']//li//p//text()")
25    # 輸出爬到的遊戲名
26    print(gameList)
複製程式碼

當我們拿到這幾十個遊戲名後,下一步就是對這幾十款遊戲進行語料爬取,這時候問題就來了,我們要從哪個網站來爬這幾十個遊戲的攻略呢,taptap?多玩?17173?在對這幾個網站進行分析後,發現這些網站僅有一些熱門遊戲的文章語料,一些冷門或者低熱度的遊戲,例如“靈魂籌碼”、“奇蹟:覺醒”、“死神來了”等,很難在這些網站上找到大量文章語料,如圖所示:

任意html主內容提取

我們可以發現,“奇蹟:覺醒”、“靈魂籌碼”的文章語料特別少,數量上不符合我們的要求。那麼有沒有一個比較通用的資源站,它擁有著無比豐富的文章語料,可以滿足我們的需求。

其實靜下心來想想,這個資源站我們天天都有用到,那就是百度。我們在百度新聞搜尋相關遊戲,拿到搜尋結果列表,這些列表的連結的網頁內容幾乎都與搜尋結果強相關,這樣我們資料來源不夠豐富的問題便輕鬆解決了。但是此時出現了一個新的問題,並且是一個比較難解決的問題——如何抓取到任意網頁的文章內容?

因為不同的網站都有不同的頁面結構,我們無法與預知將會爬到哪個網站的資料,並且我們也不可能針對每一個網站都去寫一套爬蟲,那樣的工作量簡直難以想象!但是我們也不能簡單粗暴的將頁面中的所有文字都爬下來,用那樣的語料來進行訓練無疑是噩夢!

經過與各個網站鬥智鬥勇、查詢資料與思索之後,終於找到一條比較通用的方案,下面為大家講一講筆者的思路。

0x3 任意網站的文章語料爬取

01|提取方法

1)基於Dom樹正文提取

2)基於網頁分割找正文塊

3)基於標記窗的正文提取

4)基於資料探勘或機器學習

5)基於行塊分佈函式正文提取

02|提取原理

大家看到這幾種是不是都有點疑惑了,它們到底是怎麼提取的呢?讓筆者慢慢道來。

1)基於Dom樹的正文提取:

這一種方法主要是通過比較規範的HTML建立Dom樹,然後地櫃遍歷Dom,比較並識別各種非正文資訊,包括廣告、連結和非重要節點資訊,將非正文資訊抽離之後,餘下來的自然就是正文資訊。

但是這種方法有兩個問題

① 特別依賴於HTML的良好結構,如果我們爬取到一個不按W3c規範的編寫的網頁時,這種方法便不是很適用。

② 樹的建立和遍歷時間複雜度、空間複雜度都較高,樹的遍歷方法也因HTML標籤會有不同的差異。

2) 基於網頁分割找正文塊

這一種方法是利用HTML標籤中的分割線以及一些視覺資訊(如文字顏色、字型大小、文字資訊等)。

這種方法存在一個問題:

① 不同的網站HTML風格迥異,分割沒有辦法統一,無法保證通用性。

3) 基於標記窗的正文提取:

先科普一個概念——標記窗,我們將兩個標籤以及其內部包含的文字合在一起成為一個標記窗(比如<h1>我是h1</h1>中的“我是h1”就是標記窗內容),取出標記窗的文字。

這種方法先取文章標題、HTML中所有的標記窗,在對其進行分詞。然後計算標題的序列與標記窗文字序列的詞語距離L,如果L小於一個閾值,則認為此標記窗內的文字是正文。

這種方法雖然看上去挺好,但其實也是存在問題的:

① 需要對頁面中的所有文字進行分詞,效率不高。

② 詞語距離的閾值難以確定,不同的文章擁有不同的閾值。

4)基於資料探勘或機器學習

使用大資料進行訓練,讓機器提取主文字。

這種方法肯定是極好的,但是它需要先有html與正文資料,然後進行訓練。我們在此不進行探討。

5)基於行塊分佈函式正文提取

對於任意一個網頁,它的正文和標籤總是雜糅在一起。此方法的核心有亮點:① 正文區的密度;② 行塊的長度;一個網頁的正文區域肯定是文字資訊分佈最密集的區域之一,這個區域可能最大(評論資訊長、正文較短),所以同時引進行塊長度進行判斷。

實現思路:

① 我們先將HTML去標籤,只留所有正文,同時留下標籤取出後的所有空白位置資訊,我們稱其為Ctext;

② 對每一個Ctext取周圍k行(k<5),合起來稱為Cblock;

③ 對Cblock去掉所有空白符,其文字總長度稱為Clen;

④ 以Ctext為橫座標軸,以各行的Clen為縱軸,建立座標系。

以這個網頁為例: http://www.gov.cn/ldhd/2009-11/08/content_1459564.htm 該網頁的正文區域為145行至182行。

任意html主內容提取

由上圖可知,正確的文字區域全都是分佈函式圖上含有最值且連續的一個區域,這個區域往往含有一個驟升點和一個驟降點。因此,網頁正文抽取問題轉化為了求行塊分佈函式上的驟升點和驟降點兩個邊界點,這兩個邊界點所含的區域包含了當前網頁的行塊長度最大值並且是連續的。

經過大量實驗,證明此方法對於中文網頁的正文提取有較高的準確度,此演算法的優點在於,行塊函式不依賴與HTML程式碼,與HTML標籤無關,實現簡單,準確率較高。

主要邏輯程式碼如下:

 1# 假設content為已經拿到的html
 2
 3# Ctext取周圍k行(k<5),定為3
 4blocksWidth = 3
 5# 每一個Cblock的長度
 6Ctext_len = []
 7# Ctext
 8lines = content.split('n')
 9# 去空格
10for i in range(len(lines)):
11    if lines[i] == ' ' or lines[i] == 'n':
12        lines[i] = ''
13# 計算縱座標,每一個Ctext的長度
14for i in range(0, len(lines) - blocksWidth):
15    wordsNum = 0
16    for j in range(i, i + blocksWidth):
17        lines[j] = lines[j].replace("\s""")
18        wordsNum += len(lines[j])
19    Ctext_len.append(wordsNum)
20# 開始標識
21start = -1
22# 結束標識
23end = -1
24# 是否開始標識
25boolstart = False
26# 是否結束標識
27boolend = False
28# 行塊的長度閾值
29max_text_len = 88
30# 文章主內容
31main_text = []
32# 沒有分割出Ctext
33if len(Ctext_len) < 3:
34    return '沒有正文'
35for i in range(len(Ctext_len) - 3):
36    # 如果高於這個閾值
37    if(Ctext_len[i] > max_text_len and (not boolstart)):
38        # Cblock下面3個都不為0,認為是正文
39        if (Ctext_len[i + 1] != 0 or Ctext_len[i + 2] != 0 or Ctext_len[i + 3] != 0):
40            boolstart = True
41            start = i
42            continue
43    if (boolstart):
44        # Cblock下面3箇中有0,則結束
45        if (Ctext_len[i] == 0 or Ctext_len[i + 1] == 0):
46            end = i
47            boolend = True
48    tmp = []
49
50    # 判斷下面還有沒有正文
51    if(boolend):
52        for ii in range(start, end + 1):
53            if(len(lines[ii]) < 5):
54                continue
55            tmp.append(lines[ii] + "n")
56        str = "".join(list(tmp))
57        # 去掉版權資訊
58        if ("Copyright" in str or "版權所有" in str):
59            continue
60        main_text.append(str)
61        boolstart = boolend = False
62# 返回主內容
63result = "".join(list(main_text))
複製程式碼

0x4 結語

至此我們就可以獲取任意內容的文章語料了,但這僅僅是開始,獲取到了這些語料後我們還需要在一次進行清洗、分詞、詞性標註等,才能獲得真正可以使用的語料。

後續有機會再和大家分享語料清洗這一塊。這裡有一個可愛的二維碼,大家記得關注喲~

任意html主內容提取
始發於微信公眾號: 騰訊DeepOcean

相關文章