原文 Extracting meaningful content from raw HTML
解析HTML頁面不算難事, 開源庫Beautiful Soup就給你提供了緊湊直觀的接口來處理網頁, 讓你使用喜歡的語言去實現. 但這也僅僅是第一步. 更有趣的問題是: 怎么才能把有用的信息(主題內容)從網頁中抓取出來?
我在過去的幾天里一直在嘗試回答這個問題, 以下是我找到的.
Arc90 Readability
我最喜歡的方法是叫做 Arc90 Readability 的算法. 由 Arc90 Labs 開發, 目的是用來使網頁閱讀更佳舒適(例如在移動端上). 你可以找到它的chrome plugin. 整個項目放在Google Code上, 但更有趣的是它的算法, Nirmal Patel用python實現了這個算法, 源碼在這里.
整個算法基于HTML-ID-names和HTML-CLASS-names生成了2個列表. 一個是包含了褒義IDs和CLASSes, 一個列表包含了貶義IDs和CLASSes. 如果一個tag有褒義的ID和CLASS, 那么它會得到額外的分數. 反之,如果它包含了貶義的ID和CLASS則丟分. 當我們算完了所有的tag的分數之后, 我們只需要渲染出那些得分較高的tags, 就得到了我們想要的內容.例子如下:
<div id="post"><h1>My post</h1><p>...</p></div>
<div class="footer"><a...>Contact</a></div>
第一個div tag含有一個褒義的ID (“id"=“post”), 所以很有可能這個tag包含了真正的內容(post). 然而, 第二行的tag footer是一個貶義的tag, 我們可以認為這個tag所包含的東西不是我們想要的真正內容. 基于以上, 我們可以得到如下方法:
- 在HTML源碼里找到所有的p tag(即paragraph)
- 對于每一個paragraph段落:
- 把該段落的父級標簽加入到列表中
- 并且把該父級標簽初始化為0分
- 如果父級標簽包含有褒義的屬性, 加分
- 如果父級標簽包含有貶義的屬性, 減分
- 可選: 加入一些額外的標準, 比如限定tag的最短長度
- 找到得分最多的父級tag
- 渲染得分最多的父級tag
這里我參考了Nirmal Patel的代碼, 寫了一個簡單的實現. 主要的區別是: 我在算法之前, 寫了一個用來清除雜項的代碼. 這樣最終會得到一個沒有腳本, 圖片的文本內容, 就是我們想要的網頁內容.
import re
from bs4 import BeautifulSoup
from bs4 import Comment
from bs4 import Tag
NEGATIVE = re.compile(".*comment.*|.*meta.*|.*footer.*|.*foot.*|.*cloud.*|.*head.*")
POSITIVE = re.compile(".*post.*|.*hentry.*|.*entry.*|.*content.*|.*text.*|.*body.*")
BR = re.compile("<br */? *>[ rn]*<br */? *>")
def extract_content_with_Arc90(html):
soup = BeautifulSoup( re.sub(BR, "</p><p>", html) )
soup = simplify_html_before(soup)
topParent = None
parents = []
for paragraph in soup.findAll("p"):
parent = paragraph.parent
if (parent not in parents):
parents.append(parent)
parent.score = 0
if (parent.has_key("class")):
if (NEGATIVE.match(str(parent["class"]))):
parent.score -= 50
elif (POSITIVE.match(str(parent["class"]))):
parent.score += 25
if (parent.has_key("id")):
if (NEGATIVE.match(str(parent["id"]))):
parent.score -= 50
elif (POSITIVE.match(str(parent["id"]))):
parent.score += 25
if (len( paragraph.renderContents() ) > 10):
parent.score += 1
# you can add more rules here!
topParent = max(parents, key=lambda x: x.score)
simplify_html_after(topParent)
return topParent.text
def simplify_html_after(soup):
for element in soup.findAll(True):
element.attrs = {}
if( len( element.renderContents().strip() ) == 0 ):
element.extract()
return soup
def simplify_html_before(soup):
comments = soup.findAll(text=lambda text:isinstance(text, Comment))
[comment.extract() for comment in comments]
# you can add more rules here!
map(lambda x: x.replaceWith(x.text.strip()), soup.findAll("li")) # tag to text
map(lambda x: x.replaceWith(x.text.strip()), soup.findAll("em")) # tag to text
map(lambda x: x.replaceWith(x.text.strip()), soup.findAll("tt")) # tag to text
map(lambda x: x.replaceWith(x.text.strip()), soup.findAll("b")) # tag to text
replace_by_paragraph(soup, 'blockquote')
replace_by_paragraph(soup, 'quote')
map(lambda x: x.extract(), soup.findAll("code")) # delete all
map(lambda x: x.extract(), soup.findAll("style")) # delete all
map(lambda x: x.extract(), soup.findAll("script")) # delete all
map(lambda x: x.extract(), soup.findAll("link")) # delete all
delete_if_no_text(soup, "td")
delete_if_no_text(soup, "tr")
delete_if_no_text(soup, "div")
delete_by_min_size(soup, "td", 10, 2)
delete_by_min_size(soup, "tr", 10, 2)
delete_by_min_size(soup, "div", 10, 2)
delete_by_min_size(soup, "table", 10, 2)
delete_by_min_size(soup, "p", 50, 2)
return soup
def delete_if_no_text(soup, tag):
for p in soup.findAll(tag):
if(len(p.renderContents().strip()) == 0):
p.extract()
def delete_by_min_size(soup, tag, length, children):
for p in soup.findAll(tag):
if(len(p.text) < length and len(p) <= children):
p.extract()
def replace_by_paragraph(soup, tag):
for t in soup.findAll(tag):
t.name = “p"
t.attrs = {}
空格符渲染
這個方法是主要思路很簡單: 把HTML源碼里面的所有tag (所有在<和>之間的代碼) 用空格符代替. 當你再次渲染網頁的時候, 所有的文本塊(text block)依然是”塊”狀, 但是其他部分變成了包含很多空格符的分散的語句. 你剩下唯一要做的就是把文本快給找出來, 并且移除掉所有其他的內容.
我寫了一個簡單的實現.
#!/usr/bin/python
# -*- coding: utf-8 -*-
import requests
import re
from bs4 import BeautifulSoup
from bs4 import Comment
if __name__ == "__main__":
html_string = requests.get('http://www.zdnet.com/windows-8-microsofts-new-coke-moment-7000014779/').text
soup = BeautifulSoup(str( html_string ))
map(lambda x: x.extract(), soup.findAll("code"))
map(lambda x: x.extract(), soup.findAll("script"))
map(lambda x: x.extract(), soup.findAll("pre"))
map(lambda x: x.extract(), soup.findAll("style"))
map(lambda x: x.extract(), soup.findAll("embed"))
comments = soup.findAll(text=lambda text:isinstance(text, Comment))
[comment.extract() for comment in comments]
white_string = ""
isIn = False;
for character in soup.prettify():
if character == "<":
isIn = True;
if isIn:
white_string += " "
else:
white_string += character
if character == ">":
isIn = False;
for string in white_string.split(" "): # tune here!
p = string.strip()
p = re.sub(' +',' ', p)
p = re.sub('n+',' ', p)
if( len( p.strip() ) > 50):
print p.strip()
這里有個問題是, 這個方法不是很通用. 你需要進行參數調優來找到最合適空格符長度來分割得到的字符串. 那些帶有大量markup標記的網站通常要比普通網站有更多的空格符. 除此之外, 這個還是一個相當簡單有效的方法.
開源社區的庫
有句話說: 不要重新造輪子. 事實上有很多的開源庫可以解決網頁內容抓取的問題. 我最喜歡的是Boilerpipe. 你可以在這里找到它的web服務(demo)http://boilerpipe-web.appspot.com/, 以及它的JAVA實現https://code.google.com/p/boilerpipe/. 相比較上面2個簡單的算法, 它真的很有用, 代碼也相對更復雜. 但是如果你只把它當做黑盒子使用, 確實是一個非常好的解決方法.
Best regards,
Thomas Uhrig