首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >爬虫选择器算法漫谈

爬虫选择器算法漫谈

作者头像
ACM算法日常
发布2020-10-10 11:32:00
3620
发布2020-10-10 11:32:00
举报

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

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

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

.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,里面的代码如下:

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树的根节点遍历所有节点,这样可以不用走树结构。

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

// 在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方法,

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的。

selectorList = CSSParser::parseSelector(CSSParserContext(*document, nullptr), nullptr, "span");

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

// 解析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:

//组合类型会造成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匹配的接口,参考了上面所讲的思路,最终实现的接口如下示例:

    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++的编写心得还是很有意义的,读书笔记重点围绕代码安全(包括内存安全和线程安全两方面)来展开。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-09-27,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 ACM算法日常 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档