记一次反反爬虫——Jikipedia Crawler

课程项目做个垂直搜索引擎,爬了一下小鸡词典——一个各种网络热词的百科,大概词条有上万个吧。

没想到这个过程还挺曲折复杂的,在这里记录一下过程用到的各种反反爬的技巧。

主要使用python的Scrapy和Selenium框架,代码在这里,里面还包括一些其他的爬虫(B站、微博等)。

什么,这样一个小破站还玩反爬?

一开始我看了一下网页结构也不复杂,页面就一瀑布流,词条也很轻量级。

然后看了一下url,词条是按序号排列的:https://jikipedia.com/definition/{id}

其中id目测在0-10000之间。当然超过10000的id也有,但是比较少,我们也不打算爬了。

可惜没找到请求的API,看来只能解析网页了。

呵呵,小破站估计很快就爬完了。于是我启动Scrapy就开始撸代码。

前期思路

  • 感觉网页信息挺少的,就需要获取【词条名称+词条文本+浏览点赞评论数等等】,打算先存到文本txt,再按需放到数据库。
  • 用一个字典存已经爬取的id和对应的词,为了支持断点续爬需要用pickle存到文件,下次爬取的时候恢复。
    • 没想太多,id用的是线性增长,到10000的时候截止。
  • 对网页内容解析,正则提取就ok了 。具体规则还请看源码jikipedia.py

当然,这个时候headers信息该做的伪装都做了:

# settings.py
DEFAULT_REQUEST_HEADERS = {
    'User-Agent': ('Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X)'
                   ' AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0'
                   ' Mobile/15A372 Safari/604.1'),
    'Token': '81dca7415bd81f859fcdc968afd19be1d9015f01126142bb907181bd3dbd0098',
    'Accept': 'application/json, text/plain, */*',
}

写的差不多了,走你。看着数据文件夹下文件数蹭蹭上涨,感觉甚是爽快——仿佛自己把这个站日了一遍然而我的愉悦立刻被打断了,什么,被shutdown了??

emm,打开网页地址一看:

这么潮的吗,结果跳出一个验证码。还是拖动拼图式的……好吧我怂了。

绕道通行

好吧,咱们想想解决方案:

首先,当然是:

改UA

  • 看了一下网站的robots.txt,貌似对baidubot和googlebot是来者不拒啊。
  • 于是就去抄了一堆这两家的UA,大致是这些:
# settings.py
# User-Agent to choose from
UA_LIST = [
    # Google-bot headers
    'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
    ('Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P)'
     ' AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.96'
     ' Mobile Safari/537.36 (compatible; Googlebot/2.1;'
     ' +http://www.google.com/bot.html)'),
    'Googlebot/2.1 (+http://www.google.com/bot.html)',
    'Mozilla/5.0 (compatible; Googlebot/2.1; http://www.google.com/bot.html)',
    # Baidu-bot headers
    ('Mozilla/5.0 (compatible; Baiduspider/2.0;'
     '+http://www.baidu.com/search/spider.html)'),
    ('Mozilla/5.0 (compatible;Baiduspider-render/2.0;'
     ' +http://www.baidu.com/search/spider.html)')
]
  • 再加一个中间件随机选取,代码大概长这样:
# middlewares.py
class RandomUserAgentMiddlware(object):
    """Modify random user-agent each request."""

    def __init__(self, _):
        super(RandomUserAgentMiddlware, self).__init__()

    @classmethod
    def from_crawler(cls, crawler):
        return cls(crawler)

    @staticmethod
    def process_request(request, spider):
        _ = spider  # Use this to silent warning
        # Change User-Agent
        if len(UA_LIST) > 0:
            new_ua = random.choice(UA_LIST)
            request.headers['User-Agent'] = new_ua

记得把settings.py里面的中间件配置改掉:

# settings.py
DOWNLOADER_MIDDLEWARES = {
    'MemeCrawler.middlewares.RandomUserAgentMiddlware': 543,
    'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': None
}

下载等待

感觉不太放心,加点等待时间吧。咱们这里看起来是固定等待,当然Scrapy内部还是采用随机等待的,这里的设置只是加个限制。

# settings.py
# Configure a delay for requests for the same website (default: 0)
# See https://doc.scrapy.org/en/latest/topics/settings.html#download-delay
# See also autothrottle settings and docs
DOWNLOAD_DELAY = 3.

好,继续。

结果这次爬了一会,连续到50个的时候就得开网页人工破解一下验证码。

我寻思效率还行吧,偶尔打断一下我看番的时间。

结果,再反复这样玩了3,4次之后,当我划开验证码跳出来的不是成功以后恢复的界面,而是……

这小破站可真会玩???不是,我寻思这个网站也不是很大吧……

咋办?难道还要我不停地换ip,开手机热点爬,买个ip池(算了,我穷)?

道高一尺,魔高一丈?

想了一下,为啥我的请求会不给通过?

随机序列id访问

好吧,可能是因为我请求是按照id顺序增长的,这个也很明显……

那只能随机化顺序了。于是在使用文件存储已经爬取的id的基础上,我把1-10000中剩下来待爬取的id取出做成一个随机序列,每次从序列取一个id出来。这总不能判断我是机器人了吧?

# spiders/jikipedia.py
class JikiSpider(scrapy.Spider):
  # ...omit other details...

    def init_index(self) -> None:
        # Load from index file and shuffle index
        if os.path.exists(ENTRY_INDEX_FILE):
            with open(ENTRY_INDEX_FILE, 'rb') as f:
                saved: dict = pickle.load(f)
        else:
            saved = {}
        # Get unsaved index
        all_index = np.ones(self.max_index + 1)
        all_index[list(saved.keys())] = 0
        rest_index = np.where(all_index > 0)[0]
        # Shuffle index
        np.random.shuffle(rest_index)
        self.next_sequence = rest_index
        self.saved_dict = saved
        self.current_index = -1

    def next_index(self) -> int:
        # Find index of next entry that is not saved
        self.current_index += 1
        # Return -1 if reach end of sequence
        if self.current_index < len(self.next_sequence):
            next_id = int(self.next_sequence[self.current_index])
        else:
            next_id = -1
        return next_id

    def close(self, spider, reason):
        # Dump save record before close spider
        logger.warning(reason)
        with open(ENTRY_INDEX_FILE, 'wb') as f:
            pickle.dump(self.saved_dict, f)

Selenium拟人操作

又想了想可能还是请求的时候只请求网页而没有顺带地加载css样式和js脚本,那么用浏览器可能好点。

这么一说,那Selenium走起啊。

在网上找了一些资料,于是加了一个下载中间件,把原来的request拦截后用Selenium获得response再返回给spider。

在这个过程,我又加了一些小trick:

  • 在网页加载中增加随机等待(sleep就行),模拟人的阅读过程
  • 在网页加载后随机下划页面,模拟人的阅读过程
  • 在网页加载后点击查看评论,然后再随机等待一会
  • 在网页加载后随机点赞

我都点赞了!如果还觉得我是机器人,你是不是也太智能了点?

大致代码如下:

# middlewares.py
class SeleniumMiddleware(object):
    """Middleware of browsing pages by selenium."""

    def __init__(self):
        driver = webdriver.Chrome(executable_path=DRIVER_PATH)
        # Set window size and position
        driver.set_window_position(0, 0)
        driver.set_window_size(1400, 1000)
        # Set timeout parameters
        driver.set_page_load_timeout(TIMEOUT)
        self.driver = driver
        self.wait = WebDriverWait(driver, timeout=TIMEOUT,
                                  poll_frequency=POLL_FREQUENCY)
        self.like_rate = 0.4  # Possibility of clicking `like` button
        logger.info('Selenium driver is starting...')

    def __del__(self):
        if self.driver is not None:
            self.driver.close()

    def process_request(self, request: scrapy.Request,
                        spider: scrapy.Spider) -> HtmlResponse:
        _ = spider  # Use this to silent warning
        try:
            self.driver.get(request.url)
            # Use random sleep
            sleep(random() * RANDOM_SLEEP_BASE)
            # 404 not found
            if self.driver.page_source.count('这个页面找不到了') > 0:
                response = HtmlResponse(url=request.url,
                                        request=request,
                                        status=404)
            # 200 success
            # Moss CAPTCHA
            elif self.driver.page_source.count('hello moss') > 0:
                # You can try manually do the CAPTCHA
                sleep(10)
                # Jiki will be closed
                response = HtmlResponse(url=request.url,
                                        body=self.driver.page_source,
                                        request=request,
                                        encoding='utf-8',
                                        status=200)
            else:
                # Get item height
                card = self.wait.until(
                    expected_conditions.presence_of_element_located(
                        (By.CSS_SELECTOR, '.full-card')))
                height = card.size['height']
                # Scroll page
                scroll = ('var q=document.documentElement'
                          '.scrollTop={};').format(height * random())
                self.driver.execute_script(scroll)
                # Click comment button
                comment = self.wait.until(
                    expected_conditions.element_to_be_clickable(
                        (By.CSS_SELECTOR, '.comment')))
                comment.click()
                # Randomly click like button
                if random() < self.like_rate:
                    like = self.wait.until(
                        expected_conditions.element_to_be_clickable(
                            (By.CSS_SELECTOR, '.like.button')))
                    like.click()
                # Form response
                response = HtmlResponse(url=request.url,
                                        body=self.driver.page_source,
                                        request=request,
                                        encoding='utf-8',
                                        status=200)
        except TimeoutException as e:
            logger.warning(e)
            response = HtmlResponse(url=request.url,
                                    request=request,
                                    status=500)
        except Exception as e:
            logger.warning(e)
            response = HtmlResponse(url=request.url,
                                    request=request,
                                    status=404)
        # Use random sleep after comments are shown
        sleep(random() * RANDOM_SLEEP_BASE)
        return response

    @classmethod
    def from_crawler(cls, crawler):
        _ = crawler  # Use this line to silent warning
        return cls()

嗯,这样效果好多了。

被查封的概率小了很多,然而——效率也贼低(它竟然偶尔还说我频率过高然后要我验证……再慢就属于老年人的浏览速度了啊摔)。

好吧,至少我现在可以一边自己看着词条,一边让它一直爬了。

好像还有点快乐?哈哈哈。


 Previous
见习炼丹师-01:深度学习基础 见习炼丹师-01:深度学习基础
本文是“见习炼丹师”系列第一篇文章,主要内容为深度学习的理论基础知识。 建议搭配我的另一个专栏“机器学不动了”食用。 全系列推荐结合个人实现的代码阅读:https://github.com/Riroaki/LemonML/ 欢迎star、f
Next 
机器学不动了-08:K近邻 机器学不动了-08:K近邻
本文是”机器学不动了”系列的第八篇文章,内容包含了K近邻算法的理论和实现。 全系列推荐结合个人实现的代码食用:https://github.com/Riroaki/LemonML/ 欢迎star、fork与pr。 引子这一次我们介绍的是K近