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-block 和 position: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 whole 的 class 属性,其余标题节点的内部则都分为了一个个 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卷)
我们成功爬取了书籍网站上每本书的名称!