decruft(A library to extract meaningful data from a webpage) 源码分析
开源Python模块, http://code.google.com/p/decruft/
decruft使用example,
from decruft import Document
#import urllib2
#f = urllib2.open('<em>url</em>')
f = open('index.html', 'a')
print Document(f.read()).summary()
分析一下summary的实现, 总体来说并没有什么复杂的理论, 主要就是根据段落中的word number, link density 和element的id,class的命名来判断是否为正文, 规则大部分都是出于经验和实验.
这个函数的框架上来就是个while True:循环, 为了保证抽取的质量, 首先进行严格的(ruthless)抽取, 那么之前会删掉所有不相干的element, 如果这样抽取不到正文或者抽取的正文过短, 那就需要放宽标准, 然后continue重做一遍. 下面就来看看具体的分析过程,
Web Page Parse and Clean (Preprocess)
Parse和Clean过程是交织在一起的, 主要包含下面几个步骤,
1. Clean Html
用lxml的Cleaner, from lxml.html.clean import Cleaner
这个Cleaner还是比较强大的, 你可以规定想clean哪些, 不想clean哪些.
2. 将web page转化为unicode, 这个是后续处理的基础, 语言的编码转化是个麻烦事. 幸好BeautifulSoup提供了UnicodeDammit可以自动将文档转化为Unicode, 这段代码在feedparser里面也看到过, 相当的牛比, 会根据你给出的encode, 或页面里面的encode, 或detect出的encode, 还会用到chardet, 总之就是这个模块都搞不定的, 基本就是没法转了.
content = UnicodeDammit(raw_content, isHTML=True).markup
class UnicodeDammit:
"""A class for detecting the encoding of a *ML document and
converting it to a Unicode string."""
3. 为了保证html成功被parse, 对lousy html做些预处理, 虽然lxml是可以处理broken html的
方法就是用正则表达式删除或替换掉下面的情况, 'javascript', 'double double-quoted attributes', 'unclosed tags', 'unclosed (numerical) attribute values'
4. Parse Html
调用lxml的html.fromstring载入并parse
Absolute links转化, 为了方便后续处理, 把所有的url都转化为absolute
也很方便, 用lxml的这个函数就可以解决这个问题, html_doc.make_links_absolute(base_href, resolve_base_href=True)
5. remove_unlikely_candidates
在严格(ruthless)提取的情况下, 会先去除哪些不可能是正文的element.
原理很简单, 就是把根据element的id, class来判断他们是否有可能是正文, 这个完全根据经验.
'unlikelyCandidatesRe': re.compile('share|bookmark|adwrapper|ad_wrapper|combx|comment|disqus|foot|header|menu|meta|nav|rss|shoutbox|sidebar|sponsor',re.I),
'okMaybeItsACandidateRe': re.compile('and|article|body|column|main',re.I),
规则就是, 包含unlikelyCandidatesRe, 并不包含okMaybeItsACandidateRe, 并elem不是body
6. transform_misused_divs_into_paragraphs
Transform "div"s that do not contain other block elements into "p"s, 把用的不对的"div"改成"p"
'divToPElementsRe': re.compile('<(a|blockquote|dl|div|img|ol|p|pre|table|ul)',re.I), 如果"<div>"里面不包含这些tag就改成"<p>"
Score Paragraphs and Select Best
1. score candidate paragraphs
candidates = {}
elems = self.tags(self.html, "div", "p", "td", 'li', "a")
从html中选出以上的tag element作为candidates, 对每个element做如下操作,
- inner_text = elem.text_content(), 得到inner_text , 如果len(inner_text) < min_text_length(通常为25)就滤掉
- 取 得parent_node, grand_parent_node, 如果在candidates中已经存在parent_node, grand_parent_node和当前node就不用管了(意思就是之前分析过了); 如果不存在, 要用score_node函数(后面分析)对parent_node, grand_parent_node和node打分, 并把这个初始分加到candidates中去
- 计算element的分值, 这个也是根据经验和实验,
content_score = 1 #初始分
content_score += len(inner_text.split(',')) #句子数
content_score += min([(len(inner_text) / 100), 3]) #字符串长度
- 最后是要把分值加到candidates中去, 对于node和parent_node直接加, 对于grand_parent_node先除2再加.
Scale the final candidates score based on link density. Good content should have a relatively small link density (5% or less) and be mostly unaffected by this operation.
candidate['content_score'] *= (1 - self.get_link_density(elem))
2. score_node
分析一下前面用到的score_node, 相当简单, element的id和class包含postive,+25; 包含negative, -25
'positiveRe': re.compile('caption|article|body|content|entry|hentry|page|pagination|post|text',re.I),
'negativeRe': re.compile('adwrapper|ad_wrapper|share|bookmark|nav|combx|comment|contact|foot|footer|footnote|link|media|meta|promo|related|scroll|shoutbox|sponsor|tags|widget',re.I),
再根据tag本身, 是div, blockquote加分, 是form, th减分
3.select_best_candidate
对candidates按照score进行排序, 输出Top1
Extract the Article
Code中提供了比较好的注释, 下面就是提取的思路,
Now that we have the top candidate, look through its siblings for content that might also be related. Things like preambles, content split by ads that we removed, etc.
sibling_score_threshold = max([10, best_candidate['content_score'] * 0.2]) #计算出score threshold用于筛选sibling
如果sibling满足下面任意条件就认为其中也包含了artical,
1. candidates[sibling_key]['content_score'] >= sibling_score_threshold
2. if sibling.tag == "p":
a) node_length > 80 and link_density < 0.25
b) node_length < 80 and link_density == 0 and re.search('/.( |$)', node_content)
Sanitize
对输出结果做最后的清洗,
1. 清除不满足条件的"h1", "h2", "h3", "h4", "h5", "h6", 根据class,id和link density
2. 清除"form", "iframe"
3. 有条件的清除"table", "ul", "div"
a) weight + content_score < 0, weight是根据id, class计算出的
b) len(el.text_content().split(",")) < 10, 文本小于10段的情况下, (文本多的时候就可以任务是artical)
for kind in ['p', 'img', 'li', 'a', 'embed', 'input']: counts[kind] = len(el.findall('.//%s' %kind)) #统计这些tag的数量
结合content_length, link_density进行组合, 都是根据实验和经验得出的清除rules
值得注意的一点是, 当发现存在valid的图片时, 不会清除该element
判断valid图片的rule是, (height and int(height) >= 50) or (width and int(width) >= 50), 个人觉得这个标准不太靠谱, 不知道实际效果怎么样.