2. 网页正文提取
--------[2011.8.23]更新:本文用到的算法的源码和DEMO可以从下面的SVN获取:
SVN:http://cx-extractor.googlecode.com/svn/trunk/
算法项目主页:http://code.google.com/p/cx-extractor/ -------------------
整个系统中,实现这个功能所花费的时间最多,时间主要用在选择正文提取算法上了。虽然有很多现成的方法,例如基于DOM树、标记窗、网页分割、文本分类、聚类、隐马模型、数据挖掘……%¥#@~^*&……,可对于我这个菜鸟来说,实现起来都很麻烦。毕竟我的目的是先让整个系统work起来,而不是做一个好的正文提取系统。
而且用了一些采用这些方法的在线正文提取系统之后,发现这些方法也不像吹嘘中有那么好的提取能力和泛化能力,毕竟各个网站的结构都不一样,并且这些算法本身也有局限性。比如说Dom树,它的建立对HTML是否良构要求较高,树的建立和遍历时空复杂度高,树遍历方法也因HTML标签不同会有差异。
屡次碰壁后,心中不禁黯然:到底有没有简单易行,效果又不错的方法啊!有木有啊!哎,古人云,鱼与熊掌不可兼得,这是真的吗,真的吗,真的吗?幸好,古人也不一定是对的,这种方法还真尼玛有!呃,不好意思,请原谅我的粗口,仅借它表示我在茫茫网络中突然发现这个方法时的激动心情。这个方法就是基于行块分布函数的通用网页正文抽取算法,作者是哈尔滨工业大学信息检索研究中心的陈鑫。
看到这个方法的第一眼我就知道这是我想要的算法,为什么?因为它的名字长!它的作者所在单位也长!
该算法的详细介绍可以在这里找到,本文只简单介绍一下。
基于行块分布函数的方法,可以在线性时间O(N)内抽出正文。此方法核心依据有两点:
- 正文区的密度
- 行块的长度
依据1:一个网页的正文区域肯定是文字信息分布最密集的区域之一,这个区域可能最大但不尽然,比如评论信息较长,或者网页正文新闻较短,而又出现如下大篇紧密导航信息时:
满文军涉毒后复出 “白发”露面只捐款不赚钱
唱响广东清远河源海选来开帷幕 84岁阿婆参赛
陈楚生纵贯线加盟江苏跨年演唱会
陈思思唱响“魅力汤山” 台湾归来似希腊女神
满文军白发复出 刘信达:他是染的!
江苏卫视跨年演唱会100万邀F4重聚
“叛将”陈楚生不怵龙丹妮 只要不丢脸不做一哥
满文军头发花白 复出只捐钱不挣钱(图)
依据2:行块的长度信息可以有效解决上述问题。
依据1和依据2 相结合,就能很好的实现正文提取。具体实现如下:
首先将网页HTML 去净标签,只留所有正文,同时留下标签去除后的所有空白位置信息,留下的正文称为Ctext.
定义1. 行块:
以Ctext 中的行号为轴,取其周围K 行(上下文均可,K<5,这里取K=3,方向向下, K 称为行块厚度),合起来称为一个行块Cblock,行块i是以Ctext 中行号i 为轴的行块;
定义2.行块长度:
一个Cblock,去掉其中的所有空白符(\n,\r,\t 等)后的字符总数称为该行块的长度;
定义3. 行块分布函数:
以Ctext 每行为轴,共有LinesNum(Ctext)‐K 个Cblock,做出以[1,LinesNum(Ctext)‐K]为横轴,以其各自的行块长度为纵轴的分布函数;
则行块分布函数可以在O(N)时间求得,在行块分布函数图上就可以直观的看出正文所在区域。
作者陈鑫从国内各大主流媒体中随机各选出了一篇网页,求出行块分布函数如下图所示:
从上面的图中可以看出,该方法可以识别出正文所在区域。我也求了一下两个百科的行块分布函数,其中词条“中科院研究生院”的行块分布函数如下所示:
百度百科,正确文本区域行号为76-132, 点击这里访问该词条。
互动百科,正确文本区域行号为64-91, 点击这里访问该词条。
经测试,使用该方法可以很好的提取出网页正文。为了更精确的提取出正文,我分析了两种百科的网页结构,在此方法上又做了进一步的优化。其实也就是根据特殊的关键字,缩小了一下正文的范围。
百科词条正文提取的关键代码如下:
class TextExtract { private const int blockHeight = 3; // 行快大小(方向向下) private const int threshold = 150; // 阈值 private BaikeEntry baikeEntry; private int textStart; // 网页正文开始行数 private int textEnd; // 网页正文结束行数 private string textBody; // 提取到的<boy>标签内的内容 private string[] lines; // 按行存储textBody的内容 private List<int> blockLen; // 每个行快的总字数 // 隐藏默认构造函数 private TextExtract() { } // 构造函数 public TextExtract(BaikeEntry newBaikeEntry) { baikeEntry = newBaikeEntry; textStart = 0; textEnd = 0; textBody = ""; blockLen = new List<int>(); extract(); } // 提取网页正文 public void extract() { extractTitle(); // 提取标题 extractBody(); // 提取<body>标签中的内容 removeTags(); // 去除textBody中的HTML标签 optimizeBody(); // 根据百度和互动的页面布局特征确定正文范围 extractText(); // 提取网页正文 extractPreview(); // 提取预览页面的HTML代码(去除图片和JS) } private void extractTitle() { string pattern = @"(?is)<title>(.*?)</title>"; Match m = Regex.Match(baikeEntry.sourceHTML, pattern); if (m.Success) { baikeEntry.title = m.Groups[1].Value; baikeEntry.title = Regex.Replace(baikeEntry.title, @"(?is)\s*", ""); } } private void extractBody() { string pattern = @"(?is)<body.*?</body>"; Match m = Regex.Match(baikeEntry.sourceHTML, pattern); if (m.Success) textBody = m.ToString(); } private void removeTags() { string docType = @"(?is)<!DOCTYPE.*?>"; string comment = @"(?is)<!--.*?-->"; string js = @"(?is)<script.*?>.*?</script>"; string css = @"(?is)<style.*?>.*?</style>"; string specialChar = @"&.{2,8};|&#.{2,8};"; string otherTag = @"(?is)<.*?>"; textBody = Regex.Replace(textBody, docType, ""); textBody = Regex.Replace(textBody, comment, ""); textBody = Regex.Replace(textBody, js, ""); textBody = Regex.Replace(textBody, css, ""); textBody = Regex.Replace(textBody, specialChar, ""); textBody = Regex.Replace(textBody, otherTag, ""); } private void optimizeBody() { int begin = 0; int end = 0; if (baikeEntry.siteName == "baidu") { begin = textBody.IndexOf("百科名片"); end = textBody.IndexOf("词条图册更多图册"); } else { begin = textBody.IndexOf("本词条由"); begin = textBody.IndexOf("目录", begin > 0 ? begin : 0); end = textBody.LastIndexOf("上传图片"); end = textBody.LastIndexOf("附图", end > 0 ? end : textBody.Length); } if (begin < end && begin > 0 && end > 0) textBody = textBody.Substring(begin, end - begin); } private void extractText() { // 去除每行的空白字符 lines = textBody.Split('\n'); for (int i = 0; i < lines.Length; i++) lines[i] = Regex.Replace(lines[i], @"(?is)\s*", ""); // 去除上下紧邻行为空,且该行字数小于30的行 for (int i = 1; i < lines.Length - 1; i++) { if (lines[i].Length < 30 && lines[i-1].Length == 0 && lines[i+1].Length == 0) lines[i] = ""; } // 统计去除空白字符后每个行块所含总字数 for (int i = 0; i < lines.Length - blockHeight; i++) { int len = 0; for (int j = 0; j < blockHeight; j++) len += lines[i + j].Length; blockLen.Add(len); } // 寻找各个正文块起始和结束行,并进行拼接 textStart = FindTextStart(0); if (textStart == 0) baikeEntry.errMsg = "未能提取到正文!"; else { while (textEnd < lines.Length) { textEnd = FindTextEnd(textStart); baikeEntry.text += GetText(); textStart = FindTextStart(textEnd); if (textStart == 0) break; textEnd = textStart; } } } // 如果一个行块大小超过阈值,且紧跟其后的1个行块大小不为0 // 则此行块为起始点(即连续的4行文字长度超过阈值 private int FindTextStart(int index) { for (int i = index; i < blockLen.Count - 1; i++) { if (blockLen[i] > threshold && blockLen[i + 1] > 0) return i; } return 0; } // 起始点之后,如果2个连续行块大小都为0,则认为其是结束点(即连续的4行文字长度为0) private int FindTextEnd(int index) { for (int i = index + 1; i < blockLen.Count - 1; i++) { if (blockLen[i] == 0 && blockLen[i + 1] == 0) return i; } return lines.Length - 1; } private string GetText() { StringBuilder sb = new StringBuilder(); for (int i = textStart; i < textEnd; i++) { if (lines[i].Length != 0) sb.Append(lines[i]).Append("\n\n"); } baikeEntry.errExist = false; return sb.ToString(); } private void extractPreview() { baikeEntry.preview = Regex.Replace(baikeEntry.sourceHTML, @"(?is)<[^>]*jpg.*?>", ""); baikeEntry.preview = Regex.Replace(baikeEntry.preview, @"(?is)<[^>]*gif.*?>", ""); baikeEntry.preview = Regex.Replace(baikeEntry.preview, @"(?is)<[^>]*png.*?>", ""); baikeEntry.preview = Regex.Replace(baikeEntry.preview, @"(?is)<[^>]*js.*?>" , ""); baikeEntry.preview = Regex.Replace(baikeEntry.preview, @"(?is)<script.*?>.*?</script>", ""); } }
-------------------------------------------
作者:兔纸张 来源:博客园 ( http://www.cnblogs.com/geiliCode )