兔纸张

It is the time you have wasted for your rose that makes your rose so important.

  博客园 :: 首页 :: 博问 :: 闪存 :: 新随笔 :: 联系 :: 订阅 订阅 :: 管理 ::

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)时间求得,在行块分布函数图上就可以直观的看出正文所在区域。

作者陈鑫从国内各大主流媒体中随机各选出了一篇网页,求出行块分布函数如下图所示:

clip_image001

clip_image002

clip_image003

clip_image004 

从上面的图中可以看出,该方法可以识别出正文所在区域。我也求了一下两个百科的行块分布函数,其中词条“中科院研究生院”的行块分布函数如下所示:
 

clip_image005

百度百科,正确文本区域行号为76-132, 点击这里访问该词条。

clip_image006

互动百科,正确文本区域行号为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 )

posted on 2011-07-19 23:42  geiliCode  阅读(457)  评论(0编辑  收藏  举报