字体反爬案例分析与爬取实战
本节再分析一个反爬案例,该案例将真实的数据隐藏到字体文件里,使我们即使获取了页面源代码,也没法直接提取数据的真实值。
案例介绍
案例网站为 https://antispider4.scrape.center/ ,打开之后看着和之前的电影网站没什么不同。我们按照 7.7 节类似的分析逻辑来爬取一些信息,例如电影标题、类别、评分等,代码实现如下:
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://antispider4.scrape.center/')
WebDriverWait(browser, 10).until(EC.presence_of_all_elements_located((By.CSS_SELECTOR, '.item')))
html = browser.page_source
doc = pq(html)
items = doc('.item')
for item in items.items():
name = item('.name').text()
categories = [o.text() for o in item('.categories button').items()]
score = item('.score').text()
print(f'name: {name} categories: {categories} score: {score}')
browser.close()
这里先用 Selenium 打开案例网站,等待所有电影加载出来,然后获取页面源代码,并通过 pyquery 提取和解析每一个电影的信息,得到名称、类别和评分,之后输出,运行结果如下:
name: 霸王别姬 - Farewell My Concubine categories: ['剧情', '爱情'] score:
name: 这个杀手不太冷 - Léon categories: ['剧情', '动作', '犯罪'] score:
name: 肖申克的救赎 - The Shawshank Redemption categories: ['剧情', '犯罪'] score:
name: 泰坦尼克号 - Titanic categories: ['剧情', '爱情', '灾难'] score:
name: 罗马假日 - Roman Holiday categories: ['剧情', '喜剧', '爱情'] score:
name: 唐伯虎点秋香 - Flirting Scholar categories: ['喜剧', '爱情', '古装'] score:
name: 乱世佳人 - Gone with the Wind categories: ['剧情', '爱情', '历史', '战争'] score:
name: 喜剧之王 - The King of Comedy categories: ['剧情', '喜剧', '爱情'] score:
name: 楚门的世界 - The Truman Show categories: ['剧情', '科幻'] score:
name: 狮子王 - The Lion King categories: ['动画', '歌舞', '冒险'] score:
很奇怪,结果中的 score 字段不包含任何信息,这是怎么回事?经过仔细观察,发现评分对应的源代码并不包含数字信息,如图 7-48 所示。
图7-48 评分对应的源代码
Span 节点里就什么信息都没有,提取不出来自然也不足为奇了,那页面上的评分结果是怎么显示出来的呢?
其实也是 CSS 在作怪。
案例分析
我们可以观察一下源代码,各个 span 节点的不同之处在于内部 i 节点的 class 取值不太一样。可以看到图 7-50 中一共有 3 个 span 节点,对应的 class 取值分别是 icon-789、icon-981 和 icon-504,这和显示的 9.5 有什么关系呢?
接下来观察各个 i 节点的 CSS 样式,如图 7-49 所示。
图7-49 i节点
会发现 i 节点内部有一个 ::before 字段,在 CSS 中,该字段用于创建一个伪节点,即这个节点和 i 节点或者 span 节点不一样。::before 可以往特定的节点中插入内容,同时在 CSS 中使用 content 字段定义这个内容。我们在第一个 i 节点里看到了 9 这个数字,观察另外两个 i 节点,可以看到 . 和 5,3 个内容组合起来就是 9.5。
实战
那 class 取值和 content 字段值的映射关系是怎么定义的?我们可以在浏览器中追踪 CSS 源代码,代码文件如图 7-50 所示。
图7-50 CSS 源代码文件
进入文件后,可以看到整个 CSS 源代码都在一行放着,点击 “{}” 按钮格式化代码,如图 7-51 所示。
图7-51 点击 {} 按钮
之后 CSS 源代码就被格式化了,如图 7-52 所示。
图 7-52 格式化后的 CSS 源代码
可以从中找出如下内容:
.icon-981:before {
content: ".";
}
.icon-272:before {
content: "0";
}
.icon-281:before {
content: "8";
}
.icon-789:before {
content: "9";
}
原来 class 对应的值就是一个个评分结果。这样我们就有底了,只需要解析对应的结果再做转换即可。这里需要读取 CSS 文件并提取映射关系,这个 CSS 文件是 https://antispider4.scrape.center/css/app.654ba59e.css ,其部分内容如图 7-53 所示。
图7-53 CSS 文件的部分内容
我们可以试着用 requests 库读取结果,并通过正则表达式将映射关系提取出来,代码实现如下:
import re
import requests
url = 'https://antispider4.scrape.center/css/app.654ba59e.css'
response = requests.get(url)
pattern = re.compile('\.icon-(\d+)::before\{content:"(.*?)"\}')
results = re.findall(pattern, response.text)
icon_map = {item[0]: item[1] for item in results}
这里我们首先使用 requests 库提取了 CSS 文件的内容,然后使用正则表达式进行了文本匹配,正则表达式写作 .icon-(.*?):before\{content:"(.*?)"\}, 这个正则表达式并没有考虑空格,因为 CSS 源代码本身就放在一行着而且去除了所有空格。
例如,对于如下 CSS 样式:
.icon-789:before{content:"9"}
就会提取得到两个 group,第一个是 789,第二个是 9。
这里我们使用 re 里的 findall 方法进行了内容匹配,得到的结果如下:
这个结果是由很多二元组组成的列表。我们遍历这个列表,将其赋值成字典即可,最后 icon_map 就变成了如下这样:
{
...
'at': '@',
'A': 'A',
'B': 'B',
...
'789': '9',
...
'bar': '|',
...
}
例如使用 789 索引,得到的结果就是 9。
运行测试一下:
print(icon_map['789'])
print(icon_map['437'])
运行结果:
9 3
和源代码保持一致。
所以,我们只需要修改一下提取逻辑即可,代码实现如下:
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
import requests
url = 'https://antispider4.scrape.center/css/app.654ba59e.css'
response = requests.get(url)
pattern = re.compile('\.icon-(\d+)::before\{content:"(.*?)"\}')
results = re.findall(pattern, response.text)
icon_map = {item[0]: item[1] for item in results}
def parse_score(item):
elements = item('.icon')
icon_values = []
for element in elements.items():
class_name = (element.attr('class'))
icon_key = re.search('icon-(\d+)', class_name).group(1)
icon_value = icon_map.get(icon_key)
icon_values.append(icon_value)
return ''.join(icon_values)
browser = webdriver.Chrome()
browser.get('https://antispider4.scrape.center/')
WebDriverWait(browser, 10).until(EC.presence_of_all_elements_located((By.CSS_SELECTOR, '.item')))
html = browser.page_source
doc = pq(html)
items = doc('.item')
for item in items.items():
name = item('.name').text()
categories = [o.text() for o in item('.categories button').items()]
score = parse_score(item)
print(f'name: {name} categories: {categories} score: {score}')
browser.close()
这里我们定义了 parse_score 方法,它接收一个 PyQuery 对象 item,对应一个电影条目。首先提取该 item 中所有带有 icon 这个 class 的节点,然后遍历这些节点,从 class 属性里提取对应的 icon 代号,例如 icon-789,提取的结果就是 789,和我们构造的 icon_map 是相对应的,将其赋值为 icon_key。使用 icon_key 从 icon_map 中查找对应的真实值,赋值为 icon_value。最后将 icon_value 拼合成一个字符串返回。
运行结果如下:
name: 霸王别姬 - Farewell My Concubine categories: ['剧情', '爱情'] score: 9.5
name: 这个杀手不太冷 - Léon categories: ['剧情', '动作', '犯罪'] score: 9.5
name: 肖申克的救赎 - The Shawshank Redemption categories: ['剧情', '犯罪'] score: 9.5
name: 泰坦尼克号 - Titanic categories: ['剧情', '爱情', '灾难'] score: 9.5
name: 罗马假日 - Roman Holiday categories: ['剧情', '喜剧', '爱情'] score: 9.5
name: 唐伯虎点秋香 - Flirting Scholar categories: ['喜剧', '爱情', '古装'] score: 9.5
name: 乱世佳人 - Gone with the Wind categories: ['剧情', '爱情', '历史', '战争'] score: 9.5
name: 喜剧之王 - The King of Comedy categories: ['剧情', '喜剧', '爱情'] score: 9.5
name: 楚门的世界 - The Truman Show categories: ['剧情', '科幻'] score: 9.0
name: 狮子王 - The Lion King categories: ['动画', '歌舞', '冒险'] score: 9.0