一个基于浏览器Rendering Tree的正文查找算法雏形

受到HTML5标准下Web页面复杂化和前端领域SPA再次流行的影响, 常见的基于标签节点或行块分布的正文提取算法在部分场景下已经显得有些无力. 由于一些原因, 最近我开始编写自己的正文提取算法, 本文便是这个"算法"的雏形之一.

受惠于云计算的大规模推广带来的私有云的流行, 以及PhantomJS, Nightmare等Headless browser的不断完善, 我认为当下便是新一代的基于浏览器渲染引擎的个人信息索引辅助工具走上舞台的时候. 于是我重新拾起了过去算法有意忽略(因为难以在服务器端实现)的Node尺寸部分来查找正文, 基本脱离了原有算法依赖文本的思路, 所以与其说是Browser-based, 实际上Design-based更适合一点.

当然目前的查找还存在一些明显的缺陷, 需要学习更多的样本加以完善, 必要时也应该引入其他正文提取算法加以辅助. 在性能方面, 由于依赖于浏览器Rendering Tree的生成, 所以必然会慢于现有的绝大多数正文提取算法.

另外, 坦率的讲, 这个算法相比Evernote的Clearly还有一些距离, 引入其他正文提取算法后也许可以缩短一些距离吧, 但愿如此.

(题外话: Evernote即将在本月22日关闭Clearly扩展, 说是Clipper可以替代它, 我实际测试的结果是Clearly的提取能力要比Clipper精准得多, 而本文的算法反而可以作为Clipper的替代品).

该算法用到的5个主要指标(按暂定的权重降序排列):

  1. 当前元素与整个页面的可视面积比(权重200)
  2. 当前元素与其父元素的可视面积比(权重150)
  3. 当前元素与其父元素的面积比(权重100)
  4. 当前元素加上元素外边距的面积与其父元素面积的比(权重50)
  5. 当前元素与其父元素的文本长度比(权重50)

具体的计算过程十分简单, 统计目标元素的所有子元素加权平均后结果的最大者, 然后递归调用自己, 直到被限制性的断言结束递归, 最后返回的结果就是正文. 正文提取的部分在本文的代码中没有实现, 由于没有过多依赖于文本, 我觉得分开查找和提取的功能还是比较自然的.

代码实现如下, 依赖于Babel提供的ES语法和Ramda.js函数式库:

Github Gist

'use strict'  
import R from 'ramda'

let fixOverflowVisible = ([clientValue, scrollValue]) => scrollValue / clientValue > 2 ? scrollValue : clientValue  
  , getHeight = R.compose(fixOverflowVisible, e => [e.clientHeight, e.scrollHeight])
  , getWidth = R.compose(fixOverflowVisible, e => [e.clientWidth, e.scrollWidth])
  , getWindowHeight = () => document.documentElement.clientHeight
  , getWindowWidth = () => document.documentElement.clientWidth
  , getScrollX = () => window.scrollX
  , getScrollY = () => window.scrollY
  , getX = e => e.offsetLeft
  , getY = e => e.offsetTop
  , getRelativeX = e => getX(e) - getScrollX()
  , getRelativeY = e => getY(e) - getScrollY()
  , getVisiblePart = R.curry((partFunc, windowPartFunc, relativePointFunc, e) => {
    let part = partFunc(e)
      , wPart = windowPartFunc()
      , begin = relativePointFunc(e)
      , end = begin + part
    if (begin > wPart || end < 0) {return 0}
    if (begin < 0) {begin = 0}
    if (end > wPart) {end = wPart}
    return end - begin
  })
  , getVisibleHeight = getVisiblePart(getHeight, getWindowHeight, getRelativeY)
  , getVisibleWidth = getVisiblePart(getWidth, getWindowWidth, getRelativeX)
  , getVisibleArea = e => getVisibleHeight(e) * getVisibleWidth(e)
  , getComputedStyleByList = R.curry((styleList, e) => R.pick(styleList, getComputedStyle(e)))
  , mapToList = R.compose(R.values, R.map)
  , sumBy = R.curry(R.compose(R.sum, mapToList))
  , getHorizontalMargin = R.compose(sumBy(parseFloat), getComputedStyleByList(['marginLeft', 'marginRight']))
  , getVerticalMargin = R.compose(sumBy(parseFloat), getComputedStyleByList(['marginTop', 'marginBottom']))
  , getWidthWithMargin = e => getWidth(e) + getHorizontalMargin(e)
  , getHeightWithMargin = e => getHeight(e) + getVerticalMargin(e)
  , getArea = e => getWidth(e) * getHeight(e)
  , getAreaWithMargin = e => getWidthWithMargin(e) * getHeightWithMargin(e)
  , getTextLength = e => {
    if (e && e.innerText) {
      return e.innerText.replace(/\s/g, '').length
    } else {
      return 0
    }
  }
  , divideBy = R.curry((f, a, b) => R.divide(f(a), f(b)))
  , divideByArea = divideBy(getArea)
  , divideByVisibleArea = divideBy(getVisibleArea)
  , divideByTextLength = divideBy(getTextLength)

function findMainElement(parent) {  
  const Indicators = R.mapObjIndexed((value, name) => R.merge(value, {name}), {
    AreaRatioOfParent: {func: divideByArea(R.__, parent), weight: 100}
  , AreaWithMarginRatioOfParent: {func: R.curry((child, parent) => getAreaWithMargin(child) / getArea(parent))(R.__, parent), weight: 50}
  , VisibleAreaRatioOfScreen: {func: divideByVisibleArea(R.__, document.documentElement), weight: 200}
  , VisibleAreaRatioOfParent: {func: divideByVisibleArea(R.__, parent), weight: 150}
  , TextLengthRatioOfParent: {func: divideByTextLength(R.__, parent), weight: 50}
  })
  let maybeMainElement = R.allPass([
      R.compose(R.not, R.isNil)
    , R.compose(x => x > 0, getTextLength)
    , R.compose(R.contains(R.__, [Node.ELEMENT_NODE, Node.DOCUMENT_NODE, Node.DOCUMENT_FRAGMENT_NODE]), R.prop('nodeType'))
    , R.compose(R.not, R.contains(R.__, R.map(R.toUpper, ['script', 'style', 'noscript'])), R.prop('nodeName'))
    , R.compose(R.not, R.equals(0), getArea)
    ])
    , computeScore = R.curry((indicators, e) => {
      let score = sumBy(R.identity, R.map(x => {
        let result = x.func(e) * x.weight
        return result
      }, indicators)) / sumBy(x => x.weight, indicators)
      return score
    })
  let children = R.filter(maybeMainElement, R.values(parent.children))
  if (R.isEmpty(children)) {
    return parent
  }
  let mainElement = R.reduce(R.maxBy(computeScore(Indicators)), R.head(children), children)
  if (computeScore(Indicators, mainElement) > 0.3) {
    return findMainElement(mainElement)
  }
  return parent
}