本文首发于个人博客,文章链接为:https://blog.d77.xyz/archives/f10ab485.html

前言

上一篇文章讲了如何使用 scrapy 和 selenium 搭配来爬取数据,这篇文章来写一下如何用 selenium 来爬取使用 Ajax 加载数据的网站并且过掉反爬。

环境配置

本篇文章中所用到的环境都已经在上篇文章中配置好了,不知道如何使用的小伙伴可以移步上一篇文章

开始爬取

antispider1

antispider1 说明如下:

对接 WebDriver 反爬,检测到使用 WebDriver 就不显示页面,适合用作 WebDriver 反爬练习。

WebDriver 反爬,说明使用 selenium 会被检测到。

先使用上篇文章中提到的方法来尝试下。

import scrapy
from scrapy_selenium import SeleniumRequest
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC


class AntiSpider(scrapy.Spider):
    name = 'antispider1'

    def start_requests(self):
        urls = ['https://antispider1.scrape.center/']
        for a in urls:
            yield SeleniumRequest(url=a, callback=self.parse, wait_time=8, wait_until=EC.presence_of_element_located(
                (By.CLASS_NAME, 'm-b-sm')))

    def parse(self, response, **kwargs):
        print(response.text)
        input()

运行代码,selenium 会抛出一个超时异常,因为在指定的时间内未搜索到指定的标签,所以报了超时错误。

selenium.common.exceptions.TimeoutException: Message: 

同时查看页面,很明显被检测到了,页面内容都被 JS 删掉了,接下来查找检测点,过掉反爬。

先删除 selenium 等待元素的代码,防止抛异常导致浏览器退出,让程序无限等待在 input 函数上,爬虫不会退出,浏览器也不会被关掉,方便调试。

因为这种反爬检测没有比较好的入手点,所以直接打开浏览器控制台,全局搜索字符串 Webdriver Forbidden,只找到了一处。

看样子是一个三元运算符,通过判断 window.navigator.webdriver 的值来确定是显示反爬界面还是正常加载数据。

执行 window.navigator.webdriver 可以看到它的值为 true,有两个方法可以修改它的返回值:

  • 通过 window.navigator.webdriver = undefined
  • Object.defineProperties(navigator, {webdriver:{get:()=>undefined}});

在最新版的 Chrome 上测试过这两种方法都已经失效了,直接赋值的方法虽然执行成功但是并不能修改返回值,通过修改属性的方式虽然可以修改返回值,但是在新建页面或者访问一个新的 URL 时 window.navigator.webdriver 会自动变回 true,需要在每个页面加载前执行才可以,所以问题就变成了如何在页面加载之前执行自定义的命令。

在 selenium 中可以使用 CDP(即 Chrome Devtools-Protocol)Chrome 开发工具协议可以解决这个问题,CDP 命令可以在每个页面加载前加载自定义的代码,在 CDP 中这个命令叫做 Page.addScriptToEvaluateOnNewDocument

通过 execute_cdp_cmd 函数执行 CDP 命令,代码为:

driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
            "source": """
            Object.defineProperty(navigator, 'webdriver', {
            get: () => undefined
            })"""
        })

这样只需要执行一次,Chrome 就会在每次加载页面前自动执行提前定义好的指令了。

方法有了,如何集成到现有的框架中呢?因为现在使用了第三方包集成 selenium,不能直接修改第三方包的代码,而 driver 对象又掌管在第三方包中,我们可能拿不到这个对象,怎么才能执行 CDP 命令呢?

这时候就需要去翻阅第三方包的代码,看看作者将 driver 对象保存到了哪里,如何才能获取到它。

先看第一个文件(scrapy_selenium/http.py)

class SeleniumRequest(Request):
    """Scrapy ``Request`` subclass providing additional arguments"""

    def __init__(self, wait_time=None, wait_until=None, screenshot=False, script=None, *args, **kwargs):
        # 为了便于便于查看删除了注释
        self.wait_time = wait_time
        self.wait_until = wait_until
        self.screenshot = screenshot
        self.script = script

        super().__init__(*args, **kwargs)

只是继承了 scrapy 的 Request 类,是为了方便传递四个参数给到 driver 对象,再来看另一个文件(scrapy_selenium/middlewares.py)

class SeleniumMiddleware:
    """Scrapy middleware handling the requests using selenium"""

    def __init__(self, driver_name, driver_executable_path, driver_arguments,
        browser_executable_path):
        # 为了便于便于查看删除了注释
        webdriver_base_path = f'selenium.webdriver.{driver_name}'

        driver_klass_module = import_module(f'{webdriver_base_path}.webdriver')
        driver_klass = getattr(driver_klass_module, 'WebDriver')

        driver_options_module = import_module(f'{webdriver_base_path}.options')
        driver_options_klass = getattr(driver_options_module, 'Options')

        driver_options = driver_options_klass()
        if browser_executable_path:
            driver_options.binary_location = browser_executable_path
        for argument in driver_arguments:
            driver_options.add_argument(argument)

        driver_kwargs = {
            'executable_path': driver_executable_path,
            f'{driver_name}_options': driver_options
        }

        self.driver = driver_klass(**driver_kwargs)

    @classmethod
    def from_crawler(cls, crawler):
        """Initialize the middleware with the crawler settings"""

        driver_name = crawler.settings.get('SELENIUM_DRIVER_NAME')
        driver_executable_path = crawler.settings.get('SELENIUM_DRIVER_EXECUTABLE_PATH')
        browser_executable_path = crawler.settings.get('SELENIUM_BROWSER_EXECUTABLE_PATH')
        driver_arguments = crawler.settings.get('SELENIUM_DRIVER_ARGUMENTS')

        if not driver_name or not driver_executable_path:
            raise NotConfigured(
                'SELENIUM_DRIVER_NAME and SELENIUM_DRIVER_EXECUTABLE_PATH must be set'
            )

        middleware = cls(
            driver_name=driver_name,
            driver_executable_path=driver_executable_path,
            driver_arguments=driver_arguments,
            browser_executable_path=browser_executable_path
        )

        crawler.signals.connect(middleware.spider_closed, signals.spider_closed)

        return middleware

    def process_request(self, request, spider):
        """Process a request using the selenium driver if applicable"""

        if not isinstance(request, SeleniumRequest):
            return None

        self.driver.get(request.url)

        for cookie_name, cookie_value in request.cookies.items():
            self.driver.add_cookie(
                {
                    'name': cookie_name,
                    'value': cookie_value
                }
            )

        if request.wait_until:
            WebDriverWait(self.driver, request.wait_time).until(
                request.wait_until
            )

        if request.screenshot:
            request.meta['screenshot'] = self.driver.get_screenshot_as_png()

        if request.script:
            self.driver.execute_script(request.script)

        body = str.encode(self.driver.page_source)

        # Expose the driver via the "meta" attribute
        request.meta.update({'driver': self.driver})

        return HtmlResponse(
            self.driver.current_url,
            body=body,
            encoding='utf-8',
            request=request
        )

    def spider_closed(self):
        """Shutdown the driver when spider is closed"""

        self.driver.quit()

是一个下载中间件的类,代码比较长,一块一块的看。

先来看 from_crawler 方法,通过配置文件获取到定义好的配置,然后创建当前类的对象,将爬虫关闭的信号连接到 spider_closed 方法上, 在爬虫关闭时及时执行 quit 方法关闭浏览器。

再来看初始化方法,接受四个参数,通过接收到的参数使用 import_module 方法来导入类,最后添加一些参数创建 driver 对象赋值给 self.driver,到这里就找到了 driver,可以想办法执行 CDP 方法了。

最后就是 process_request 方法,使用 get 方法来获取网页源代码,将 request 对象的 cookie 都添加到 driver 对象中,根据参数值的不同执行不同的动作,等待、截图、执行代码等等,通过 meta 属性公开了 driver 对象,方便在请求完页面数据后使用其他中间件来进行点击、滑动、翻页等等动作,最后返回一个 THML 响应对象。

虽然最后通过 meta 属性公开了 driver 对象,但这是在获取到网页源代码之后了,我们需要在网页加载前执行对应 CDP 命令才可以。

为了在页面加载之前执行命令,所以我们需要自定义一个自己的下载中间件,继承 SeleniumMiddleware 类,修改父类初始化方法。

middlewares.py 文件中添加以下代码,别忘了导入 SeleniumMiddleware

class MyDownloadMiddleware(SeleniumMiddleware):
    def __init__(self, driver_name, driver_executable_path, driver_arguments,
                 browser_executable_path):
        super(MyDownloadMiddleware, self).__init__(driver_name, driver_executable_path, driver_arguments,
                                                   browser_executable_path)
        self.driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
            "source": """
            Object.defineProperty(navigator, 'webdriver', {
            get: () => undefined
            })"""
        })

先执行 super 方法初始化父类,再使用父类创建好的 driver 对象执行 CDP 命令。别忘了去 settings.py 中修改下载中间件的值为:

DOWNLOADER_MIDDLEWARES = {
    'learnscrapy.middlewares.MyDownloadMiddleware': 800  # 这里的数值要大一些,因为中间件返回响应后对象后就不会调用后续的下载中间件了
}

重新运行爬虫,应该可以看到页面正常加载了,并且网页源代码也可以正常获取到了,之后再补充上具体的解析代码即可。

class AntiSpider(scrapy.Spider):
    name = 'antispider1'

    def start_requests(self):
        urls = ['https://antispider1.scrape.center/']
        for a in urls:
            yield SeleniumRequest(url=a, callback=self.parse, wait_time=3, wait_until=EC.presence_of_element_located(
                (By.CLASS_NAME, 'm-b-sm')))

    def parse(self, response, **kwargs):
        result = response.xpath('//div[@class="el-card item m-t is-hover-shadow"]')
        for a in result:
            item = SSR1ScrapyItem()
            item['title'] = a.xpath('.//h2[@class="m-b-sm"]/text()').get()
            item['fraction'] = a.xpath('.//p[@class="score m-t-md m-b-n-sm"]/text()').get().strip()
            item['country'] = a.xpath('.//div[@class="m-v-sm info"]/span[1]/text()').get()
            item['time'] = a.xpath('.//div[@class="m-v-sm info"]/span[3]/text()').get()
            item['date'] = a.xpath('.//div[@class="m-v-sm info"][2]/span/text()').get()
            url = a.xpath('.//a[@class="name"]/@href').get()
            print(response.urljoin(url))
            yield SeleniumRequest(url=response.urljoin(url), callback=self.parse_person, meta={'item': item},
                                  wait_time=3, wait_until=EC.presence_of_element_located((By.CLASS_NAME, 'm-b-sm')))

    def parse_person(self, response):
        item = response.meta['item']
        item['director'] = response.xpath(
            '//div[@class="directors el-row"]//p[@class="name text-center m-b-none m-t-xs"]/text()').get()
        yield item

完整代码详见https://github.com/libra146/learnscrapy/tree/antispider1

其实在页面加载之前执行自定义的 JS 代码还有另外一种方法,那就是 Chrome 拓展,可以使用类似于油猴插件的拓展来实现,限于篇幅问题这里就不演示了。

antispider2

antispider2 说明如下:

对接 User-Agent 反爬,检测到常见爬虫 User-Agent 就会拒绝响应,适合用作 User-Agent 反爬练习。

既然是 User-Agent 反爬,那么就使用正常的 User-Agent 就可以了,暂时不需要用到 selenium。

本来是想用 fake-useragent 的,后来看了下项目两年多没更新了,而且不是随机生成,只是从网上下载一些 UA,然后随机选取而已,这样的话没必要引入一个依赖了,自己将 UA 爬下来然后随机取就好了。

在下载中间件中添加以下代码:

class Antispider2DownloaderMiddleware(LearnscrapyDownloaderMiddleware):
    def __init__(self):
        super(Antispider2DownloaderMiddleware, self).__init__()
        with open('ua.json', 'r') as f:
            self.ua = json.load(f)

    def process_request(self, request, spider):
        request.headers.update({'User-Agent': random.choice(self.ua)})

读取本地文件,然后在 process_request 函数中每次随机去一个 UA 更新默认的 UA 即可。

完整代码详见https://github.com/libra146/learnscrapy/tree/antispider2

antispider3

antispider3 说明如下:

对接文字偏移反爬,所见顺序并不一定和源码顺序一致,适合用作文字偏移反爬练习。

网站使用到了文字偏移反爬,猜测应该使用了 CSS 控制网页文字的位置来达到反爬的目的。

看了下渲染后的网页源代码,的确是通过改变 style 的值来使文字发生偏移的,处理方法就是将文字和 style属性一起获取,然后按照 style 升序排列就可以得到正确的结果:思维改变生活。往下看了看有的文字有偏移有的文字没有偏移,需要在代码里进行判断。

开始写代码,解析 HTML,获取数据,顺便获取对应的 style,处理后得到顺序正确的数据。

class AntiSpider(scrapy.Spider):
    name = 'antispider3'

    def start_requests(self):
        urls = ['https://antispider3.scrape.center/']
        for a in urls:
            yield SeleniumRequest(url=a, callback=self.parse, wait_time=3, wait_until=EC.presence_of_element_located(
                (By.CLASS_NAME, 'm-b-sm')))

    def parse(self, response, **kwargs):
        result = response.xpath('//div[@class="el-card__body"]')
        for a in result:
            item = Antispider3ScrapyItem()
            chars = {}
            # 有反爬
            if r := a.xpath('.//h3[@class="m-b-sm name"]//span'):
                for b in r:
                    chars[b.xpath('.//@style').re(r'\d\d?')[0]] = b.xpath('.//text()').get().strip()
                # 先用sorted函数来排序,使用lambda指定索引值为0的值,也就是根据key值来排序,排序后使用zip函数来将所有的字符串放到
                # 同一个元组中,list函数用来将生成器转成列表,之后使用索引值选择title所在的元组,使用join函数连接所有的字符串即为标题字符串
                item['title'] = ''.join(list(zip(*sorted(chars.items(), key=lambda i: i[0])))[1])
            else:
                # 没有反爬
                item['title'] = a.xpath('.//h3[@class="name whole"]/text()').get()
            item['author'] = a.xpath('.//p[@class="authors"]/text()').get().strip()
            url = a.xpath('.//a/@href').get()
            print(response.urljoin(url))
            yield SeleniumRequest(url=response.urljoin(url), callback=self.parse_person, meta={'item': item},
                                  wait_time=3, wait_until=EC.presence_of_element_located((By.CLASS_NAME, 'm-b-sm')))

    def parse_person(self, response):
        item = response.meta['item']
        item['price'] = response.xpath('//p[@class="price"]/span/text()').get()
        item['time'] = response.xpath('//p[@class="published-at"]/text()').get()
        item['press'] = response.xpath('//p[@class="publisher"]/text()').get()
        item['page'] = response.xpath('//p[@class="page-number"]/text()').get()
        item['isbm'] = response.xpath('//p[@class="isbn"]/text()').get()
        yield item

通过判断对应元素是否存在的方式来判断 title 是否被反爬,在判断分支中进行不同的处理。

由于时间关系代码中只爬取了一页数据,证明方法可行就可以。

有个插曲,其实这个网站的数据也是通过 Ajax 请求的, 也就是说直接从接口请求就可以获取到数据,不用处理反爬措施,这里是为了学习文字偏移反爬才从 HTML 中获取数据。

完整代码详见https://github.com/libra146/learnscrapy/tree/antispider3

antispider4

antispider4 说明如下:

对接字体文件反爬,显示的内容并不在 HTML 内,而是隐藏在字体文件,设置了文字映射表,适合用作字体反爬练习。

字体反爬,这种情况下需要先找到字体映射表,并且解析字体映射表中的文字和代码的对应关系才可以正常爬取。

但是我在看到这里之后我发现这好像不是字体反爬😂(虽然这个网站的确有一个单独的字体文件),数字内容被放在了 CSS 样式表文件中,虽然我是第一次见到这种反爬措施,但是我认为叫它 CSS 反爬好像更合理一些。

不知道是不是作者搞错了的原因,这里暂且当作 CSS 反爬来处理吧。

这种反爬措施需要将 HTML 源码中对应数字的 class 的值都抓出来,然后将 CSS 文件中对应的 value 替换就可以了,所以首先需要处理的是 CSS 文件,而不是 HTML。

查了下,这种使用方式叫隐式 Style–CSS.

CSS中,::before 创建一个伪元素,其将成为匹配选中的元素的第一个子元素。常通过 content 属性来为一个元素添加修饰性的内容。
class AntiSpider(scrapy.Spider):
    name = 'antispider4'
    css = {}

    def start_requests(self):
        urls = ['https://antispider4.scrape.center/css/app.654ba59e.css']
        for a in urls:
            # 解析css
            yield Request(url=a, callback=self.parse_css)

    def parse_css(self, response):
        # 根据规律使用正则找到所有需要用到的属性,由于这里只反爬了分数,所以只需要匹配少量的数字和点即可。
        result = re.findall(r'\.(icon-\d*?):before{content:"(.*?)"}', response.text)
        for key, value in result:
            self.css[key] = value
        print(self.css)
        # 访问主页
        yield SeleniumRequest(url='https://antispider4.scrape.center/', callback=self.parse_data,
                              wait_time=3, wait_until=EC.presence_of_element_located((By.CLASS_NAME, 'm-b-sm')))

    def parse_data(self, response):
        result = response.xpath('//div[@class="el-card item m-t is-hover-shadow"]')
        for a in result:
            item = Antispider4ScrapyItem()
            item['title'] = a.xpath('.//h2[@class="m-b-sm"]/text()').get()
            if r := a.xpath('.//p[@class="score m-t-md m-b-n-sm"]//i'):
                item['fraction'] = ''.join([self.css.get(b.xpath('.//@class').get()[5:], '') for b in r])
            item['country'] = a.xpath('.//div[@class="m-v-sm info"]/span[1]/text()').get()
            item['time'] = a.xpath('.//div[@class="m-v-sm info"]/span[3]/text()').get()
            item['date'] = a.xpath('.//div[@class="m-v-sm info"][2]/span/text()').get()
            url = a.xpath('.//a[@class="name"]/@href').get()
            print(response.urljoin(url))
            yield SeleniumRequest(url=response.urljoin(url), callback=self.parse, meta={'item': item},
                                  wait_time=3, wait_until=EC.presence_of_element_located((By.CLASS_NAME, 'm-b-sm')))

    def parse(self, response, **kwargs):
        item = response.meta['item']
        item['director'] = response.xpath(
            '//div[@class="directors el-row"]//p[@class="name text-center m-b-none m-t-xs"]/text()').get()
        yield item

处理方式就是先将 CSS 文件中所需要用到的内容使用正则匹配出来,在需要替换的地方直接替换就可以得到正确的分数数据了。

完整代码详见https://github.com/libra146/learnscrapy/tree/antispider4

总结

本篇文章只写针对 selenium 出现的各种反爬措施,针对 IP 地址或者账号进行的反爬的内容下篇文章来写。

网页获取数据的方式无外乎就那几种,HTML,JS,CSS,Ajax等,所以在遇到反爬时先找数据是怎么被渲染出来的,剩下的问题就是处理数据,根据数据的来源进行针对性的处理。


LLLibra146
35 声望6 粉丝

会修电脑的程序员