CSS位置偏移反爬案例分析与爬取实战

我们学习了 Selenium、Pyppeteer 等工具体会了它们的强大,但千万别以为这些工具就是万能的,不容易爬取的数据依然存在,例如网页利用 CSS 控制文字的偏移位置,或者通过一些特殊的方式隐藏关键信息,都有可能对数据爬取造成干扰。

本节先了解 CSS 位置偏移反爬虫的一些解决方案。

案例

先介绍一个案例网址 https://antispider3.scrape.center/,页面如图 7-44 所示。

图7-44 书籍网站

乍一看似乎也没什么特别之处,但如果真用 Selenium 等工具爬取和提取数据,坑就立马显现出来了,不妨一试。

先尝试用 Selenium 获取首页的页面源代码,并解析每个标题的内容:

from selenium import webdriver
from pyquery import PyQuery as pq
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait

browser = webdriver.Chrome()
browser.get('https://antispider3.scrape.center/')
WebDriverWait(browser, 10).until(EC.presence_of_all_elements_located((By.CSS_SELECTOR, '.item')))
html = browser.page_source
doc = pq(html)
names = doc('.item .name')
for name in names.items():
    print(name.text())
browser.close()

这里我们使用 Selenium 打开 Chrome 浏览器,然后使用 WebDriverWait 对象的 until 方法指定了等待加载的内容,确保首页上每本书的信息都可以加载出来。之后输出页面源代码,使用 pyquery 将标题中的纯文本解析出来,一切看起来似乎非常正常对不对?

然而运行结果是这样的:

Wonder
风清白家
桔枇上册下完(法终老葛)的
为已(册全)士扣二
那些年,我们一起追的女孩
全三()咸倾我卿非
些那儿朝事明
我的书总和笑你
一王小波卷集全
惮动然心
龙榆绵平史(全3册)
艳三册传全(寿龙)
黎街明之
认示其理启学知心及
银河帝国2:基地与帝国
:帝基银国河地
解材小-文四下级学教语平全
越界言论(第3卷)

结果中有很多标题的文字顺序是乱的,例如《明朝那些事儿》对应的输出结果是“些那儿朝事明”,这是怎么回事?

排查

我们去浏览器里面研究一下源代码,如图 7-45 所示。

图 7-45 书籍网站的首页源代码

可以发现,一个字对应一个 span 节点,这个节点本身的顺序就是乱的,所以用 pyquery 提取出来的标题内容乱序就不不足为怪了。

源代码中的文字本身是乱的,那为什么在网页上看到的标题是正确的?这是因为网页本身利用 CSS 控制了文字的偏移位置,什么意思呢?观察下源代码:

<h3 data-v-7f1a77ef="" class="m-b-sm name">
  <span data-v-7f1a77ef="" class="char" style="left: 16px"> 朝 </span>
  <span data-v-7f1a77ef="" class="char" style="left: 64px"> 事 </span>
  <span data-v-7f1a77ef="" class="char" style="left: 48px"> 卷 </span>
  <span data-v-7f1a77ef="" class="char" style="left: 0px"> 明 </span>
  <span data-v-7f1a77ef="" class="char" style="left: 32px"> 聊 </span>
  <span data-v-7f1a77ef="" class="char" style="left: 80px"> 儿 </span>
</h3>

可以发现,每个 span 节点都有一个 style 属性,表示 CSS 样式,left 的取值各不相同。另外,在浏览器中观察一下每个 span 节点的完整样式,如图 7-46 所示。

图7-46 span 节点的完整样式

可以看到,span 节点还有两个额外的样式,是 display:inline-blockposition:absolute,后者比较重要,代表绝对定位,设置这个样式后,就可以通过修改 left 的值控制 span 节点在页面中的偏移位置了,例如 left:0px 代表不偏移;left:16px 代表从左边算起向右偏移 16 像素,于是节点就到了右边。

源代码中,“明” 字的偏移是 0,“朝” 字的偏移是 16 像素,“那” 字的偏移是 32 像素,以此类推最终标题的视觉效果就变成了 “明朝那些事儿”

爬取

了解了基本原理后,我们就可以有的放了。这里只需要获取每个 span 节点的 style 属性,提取出偏移值,然后排序就可以得到最终结果了。

先实现基本的提取方法:

from selenium import webdriver
from pyquery import PyQuery as pq
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait
import re

def parse_name(name_html):
    chars = name_html('.char')
    items = []
    for char in chars.items():
        items.append({
            'text': char.text().strip(),
            'left': int(re.search('(\d+)px', char.attr('style')).group(1))
        })
    items = sorted(items, key=lambda x: x['left'], reverse=False)
    return ''.join([item.get('text') for item in items])

browser = webdriver.Chrome()
browser.get('https://antispider3.scrape.center/')
WebDriverWait(browser, 10).until(EC.presence_of_all_elements_located((By.CSS_SELECTOR, '.item')))
html = browser.page_source
doc = pq(html)
names = doc('.item .name')
for name_html in names.items():
    name = parse_name(name_html)
    print(name)
browser.close()

这里我们定义了一个 parse_name 方法,用来解析页面源代码得到最终的标题。

它接收一个参数 name_html,就是标题的 HTML 文本,类似这样:

<h3 data-v-7f1a77ef="" class="m-b-sm name">
   <span data-v-7f1a77ef="" class="char" style="left: 16px"> 朝 </span>
   <span data-v-7f1a77ef="" class="char" style="left: 64px"> 事 </span>
   <span data-v-7f1a77ef="" class="char" style="left: 48px"> 卷 </span>
   <span data-v-7f1a77ef="" class="char" style="left: 0px"> 明 </span>
   <span data-v-7f1a77ef="" class="char" style="left: 32px"> 聊 </span>
   <span data-v-7f1a77ef="" class="char" style="left: 80px"> 儿 </span>
</h3>

parse_name 方法中,我们首先选取 .char 节点,将其赋值为 chars 变量,然后遍历 chars 变量,其中每个条目各自对应一个 span 节点,其内容类似于:

<span data-v-7f1a77ef="" class="char" style="left: 16px"> 朝 </span>

在遍历的过程中,我们提取了 span 节点的文本内容作为字典的 text 属性,还提取了 style 属性的内容,例如这里提取的是 16px,并用正则表达式提取了其中的数值,这里是 16,将其赋值为字典的 left 属性。

遍历结束后,items 的结果类似下面这样:

面对这样的结果,怎么排序呢?直接调用 sorted 方法就行,它有两个参数,一个是 key,用来指定根据什么排序,这里我们直接使用 lambda 表达式提取 span 节点的 left 属性,所以最终结果是根据 left 的值排序而得;另一个参数是 reverse,用来指定排序方式,此处将其设置为 False,表示从小到大排序。

排序完的items变成了这样

最后将其中的 text 值提取出来并拼接,就得到了最终结果。代码的运行结果如下:

代码的运行结果如下:

清白家风
法老的宠妃终结篇(上下册)
士为知己(全二册)
那些年,我们一起追的女孩
非我倾城(全三册)
明朝那些事儿
我和你的笑忘书
王小波全集第一卷
怦然心动
龙枪传奇(全三册)
黎明之街
认知心理学及其启示
银河帝国:基地
小学教材全解-四年级语文下

等等,似乎少了几个标题,内容中间为什么会出现空余?

再继续排查,会发现有些标题节点内部没有分为一个个 span 节点,这些标题内部的文字本身就有序,如图 7-47 所示。

图7-47 本身就有序的标题

经过观察和推测,不难发现内部没有 span 节点的 h3 标题节点都带有一个额外的取值为 name wholeclass 属性,其余标题节点的内部则都分为了一个个 span 节点。

搞清楚问题所在,接下来稍微加判断即可,改写解析方法:

def parse_name(name_html):
    has_whole = name_html('.whole')
    if has_whole:
        return name_html.text()
    else:
        chars = name_html('.char')
        items = []
        for char in chars.items():
            items.append({
                'text': char.text().strip(),
                'left': int(re.search('(\d+)px', char.attr('style')).group(1))
            })
        items = sorted(items, key=lambda x: x['left'], reverse=False)
        return ''.join([item.get('text') for item in items])

运行结果如下:

Wonder
清白家风
法老的宠妃终结篇(上下册)
士为知己(全二册)
那些年,我们一起追的女孩
非我倾城(全三册)
明朝那些事儿
我和你的笑忘书
王小波全集第一卷
怦然心动
龙枪编年史(全3册)
龙枪传奇(全三册)
黎明之街
认知心理学及其启示
银河帝国2:基地与帝国
银河帝国:基地
小学教材全解-四年级语文下
越界言论(第3卷)

我们成功爬取了书籍网站上每本书的名称!

总结

本节分析的是一个特殊案例,通过这个案例可以知道,有时候我们使用 Selenium 爬取的内容并不一定和亲眼所见的完全符合,所以还需要小心。