爬虫选择器算法漫谈

2020-10-10 11:32:00 浏览数 (1)

爬虫选择器其实就是CSS选择器,和前端开发关系密切,这里先简单介绍一下,让没做过web开发的有个大概了解。

前端开发涉及到的东西很多,虽然最终只是展示一个web页面,然而混合了各种技术,标签语言HTML,样式语言CSS,脚本语言Javascript,这3个结合在一起堪称完美,Web GUI可以算是一个UI系统的典范,至少目前看来是没有更加先进的理念能够打破这个规范。

今天主要时一起来了解下CSS语言规则,如果你写过一些CSS样式,对其规则肯定不陌生,CSS规则看起来是这样的:

代码语言:javascript复制
.text-content pre,.text-content pre[class*=language-],pre.syntaxbox,pre.twopartsyntaxbox {
    border-left-width: 5px
}

.text-content html[dir=rtl] pre,html[dir=rtl] .text-content pre,html[dir=rtl] pre.syntaxbox,html[dir=rtl] pre.twopartsyntaxbox {
    border-left-width: 0;
    border-right-width: 5px
}

.text-content pre>:last-child,pre.syntaxbox>:last-child,pre.twopartsyntaxbox>:last-child {
    margin-bottom: 0
}

.text-content pre>:last-child,pre.syntaxbox>:last-child,pre.twopartsyntaxbox>:last-child {
    padding-bottom: 0
}

.text-content pre p,.text-content pre[class*=language-] p,pre.syntaxbox p,pre.twopartsyntaxbox p {
    box-sizing: border-box;
    width: 100%;
    max-width: 100%
}

对于每一个HTML元素,可能有一个或者多个CSS样式能够与之匹配,这个匹配的过程就叫做CSS Select,每一项规则都是一个Selector,对于一个页面来说,可能存在大量的CSS Selector,CSS的匹配规则可能很复杂(你永远不知道前端同学会怎样折腾),也因此CSS匹配需要非常高效才行。

对于匹配,第一感觉能够比较高效进行表达的应该是编译原理的语法树,如果能够定义一套正确的BNF范式,说不定会很合适。

BNF范式非常清晰,用来表达程序语言再自然不过,而且找问题也很方便直观,但是真正用Flex和Yacc来写语言的却很少见过,因为不够高效,另外这两个工具生成的代码量很大,写语言的程序员一般功力深厚都是直接撸代码完成词法和语法分析,可能会借鉴BNF的思路,具体实现一般会用C语言或者汇编。

网上其实很少有人手写一个CSS规则匹配算法,可能是用到的场景不多,另外一个也是因为实现起来有一定的工作量,做到高效并不容易。翻了一遍Github也没找到合适的库,碰到一个用Flex做的解析器,功能应该比较完善,但是没找到纯C/C 实现的,看来还是只能从谷歌浏览器的源代码里找标准答案了。

使用VSCode打开8000多个工程4万多个源代码文件的Chromium源码(深吸一口气)。

Chromium浏览器里面的CSS规则匹配是用C 代码实现的,一个完整的实现差不多有几千行,代码量不算很多,那就一起看一下吧。

我们从测试代码开始看,Chromium的代码都非常健壮,每个模块和类都会有专门的单元测试,测试代码也很简单清晰。CSS匹配的测试用例在Webkit的dom子目录中,Chromium的页面渲染引擎虽然叫做Blink,但是大部分代码还是Webkit。找到SelectorQueryTest.cpp,里面的代码如下:

代码语言:javascript复制
TEST(SelectorQueryTest, NotMatchingPseudoElement)
{
    // 构造一个document
    Document* document = Document::create();
    // 构造一个element元素,估计是根节点
    HTMLHtmlElement* html = HTMLHtmlElement::create(*document);
    document->appendChild(html);
    // 设置html内容
    document->documentElement()->setInnerHTML("<body><style>span::before { content: 'X' }</style><span></span></body>", ASSERT_NO_EXCEPTION);

    // 查询span::before元素
    CSSSelectorList selectorList = CSSParser::parseSelector(CSSParserContext(*document, nullptr), nullptr, "span::before");
    // 构建query查询器
    std::unique_ptr<SelectorQuery> query = SelectorQuery::adopt(std::move(selectorList));
    // 查询第一个元素
    Element* elm = query->queryFirst(*document);
    // 断言为nullptr
    EXPECT_EQ(nullptr, elm);

    // 查询span元素
    selectorList = CSSParser::parseSelector(CSSParserContext(*document, nullptr), nullptr, "span");
    // 构建query查询器
    query = SelectorQuery::adopt(std::move(selectorList));
    // 查询第一个元素
    elm = query->queryFirst(*document);
    // 断言为找到元素
    EXPECT_NE(nullptr, elm);
}

流程很简单,也就是通过需要查询的内容构造一个CSSSelectorList,然后通过CSSSelectorList构造一个query对象,通过query查询document里面是否有匹配的元素。

这里面分了2步来做查询,第一步是需要创建CSSSelectorList,这个CSSSelectorList是干啥的呢?不难想象,这个对象可以简单的看作是存储CSS规则的数据结构,也就是将前端开发人员的CSS规则转换为内部的C 对象,这个转换过程理想情况下应该是O(n)复杂度,n为规则字符串长度,也就是一次遍历,Chromium的实现也确实是做到了这种程度。

第2步就要复杂一些了,因为要在整棵HTML DOM树上进行查找,Chromium在这一步上做了一点优化,查询分为快查和慢查,对于比较简单的规则,比如只是查个id,或者查class、element,就是快查,快查是直接使用dom树的根节点遍历所有节点,这样可以不用走树结构。

慢查也可以说是标准树形查找,从根节点出发,依次匹配规则。如下代码

代码语言:javascript复制
// 在element元素下匹配满足selector的节点
inline bool SelectorDataList::selectorMatches(const CSSSelector& selector, Element& element, const ContainerNode& rootNode) const
{
    SelectorChecker::Init init;
    init.mode = SelectorChecker::QueryingRules;
    init.isQuerySelector = true;
    // 构造SelectorChecker(检查器)
    SelectorChecker checker(init);
    SelectorChecker::SelectorCheckingContext context(&element, SelectorChecker::VisitedMatchDisabled);
    context.selector = &selector;
    context.scope = &rootNode;
    // 开始查找
    return checker.match(context);
}

最终调用了SelectorChecker对象来进行查找操作。接着看这个match方法,

代码语言:javascript复制
bool match(const SelectorCheckingContext& context, MatchResult& result) const
    {
        ASSERT(context.selector);
        return matchSelector(context, result) == SelectorMatches;
    }

继续看matchSelector函数会发现,匹配的过程最重要的是处理好节点父子关系(relation),然后是Pseudo这种伪类产生的特殊处理。

处理relation的函数是matchForRelation,这个函数会递归调用matchSelector,也就是一层一层的往叶子节点查找,如果匹配上了就放到result对象中。这个过程细节很多,CSS的规则有几十种类型,每个类型都要处理,所以就不细讲了。

回过头来,让我们再看一下CSS规则文本是如何转换为C 中的数据结构CSSSelectorList的。

代码语言:javascript复制
selectorList = CSSParser::parseSelector(CSSParserContext(*document, nullptr), nullptr, "span");

显然是parseSelector函数作为模块的对外API,这个函数最终会调用CSSSelectorParser的parseSelector函数。如下:

代码语言:javascript复制
// 解析CSS规则,range可以理解为词法分析遍历器,有peek和consume这种函数很方便处理字符串
CSSSelectorList CSSSelectorParser::parseSelector(CSSParserTokenRange range, const CSSParserContext& context, StyleSheetContents* styleSheet)
{
    CSSSelectorParser parser(context, styleSheet);
    // 消耗掉空白
    range.consumeWhitespace();
    // 消耗一个复杂的select list
    CSSSelectorList result = parser.consumeComplexSelectorList(range);
    if (!range.atEnd())
        return CSSSelectorList();

    recordSelectorStats(context, result);
    return result;
}

重点函数consumeComplexSelectorList:

代码语言:javascript复制
//组合类型会造成list多个元素
CSSSelectorList CSSSelectorParser::consumeComplexSelectorList(CSSParserTokenRange& range)
{
    Vector<std::unique_ptr<CSSParserSelector>> selectorList;
    // 解析一个selector
    std::unique_ptr<CSSParserSelector> selector = consumeComplexSelector(range);
    if (!selector)
        return CSSSelectorList();

    selectorList.append(std::move(selector));

    // 碰到逗号,组合类型,继续解析一个个selector
    while (!range.atEnd() && range.peek().type() == CommaToken) {
        // consume掉空白
        range.consumeIncludingWhitespace();
        selector = consumeComplexSelector(range);

        if (!selector)
            return CSSSelectorList();
        selectorList.append(std::move(selector));
    }

    // 失败处理
    if (m_failedParsing)
        return CSSSelectorList();
    // 返回list
    return CSSSelectorList::adoptSelectorVector(selectorList);
}

consumeComplexSelector里面是解析单个的selector,其实就是词法分析取token,只不过都是C 直接处理各种类型,虽然琐碎,但是很高效。

代码看到这里其实已经能够看得清CSS匹配的全貌了,细节处理虽然复杂,大体思路却比较简单直接。

CSS匹配的用途最典型的是网页爬虫,如果哪天出了一个新的程序设计语言,这个语言又没有现成的爬虫组件,那么你自己来实现一个简单或复杂的CSS选择器,参考Chromium的源码思路就能很好的解决问题。

Python语言中有一个BeatifulSoup的库,主要是做这样的事情,但是很多语言都没有这么完善的库,比如Lua语言。Lua语言简短精悍,比较适合作为C/C 的辅助语言,在我的笔记软件中,将Lua语言作为插件语言,为了能够提供一个CSS匹配的接口,参考了上面所讲的思路,最终实现的接口如下示例:

代码语言:javascript复制
    local content = download_codeforces(problem)
    local root = html:parse(content)
    local markdown = ""

    local title = root:select("div.header div.title")
    if title then
        markdown = markdown.."# "..title:markdown().."n"
    end

    local body = title.parent:next_sibling()
    if body then
        markdown = markdown..body:markdown().."n"
    end

    local input_node = root:select("div.input-specification")
    if input_node then
        markdown = markdown.."### "..input_node:markdown().."n"
    end

这段脚本的功能是解析codeforces题目页面,通过CSS选择器匹配标题、内容、输入3个部分的element,然后将其转换为markdown文本插入到笔记中,用起来和python一样方便。

我这里写的比较简短,能大概理解过程就行,说不定哪天面试会问你这种问题的思路。如果你有更好的思路欢迎留言。

另外后面dansen小编会开始写一个C 方面的读书笔记专题,先从Effective C 这本书开始,因为最近深受多线程问题的困扰,为了修改笔记软件崩溃问题,查了比较多的资料,虽然最终发现问题和多线程安全关系不大,但是还是学到了很多能够提高编写C 程序的知识。本号读者大部分都会C/C ,所以讨论C 的编写心得还是很有意义的,读书笔记重点围绕代码安全(包括内存安全和线程安全两方面)来展开。

0 人点赞