课程项目做个垂直搜索引擎,爬了一下小鸡词典——一个各种网络热词的百科,大概词条有上万个吧。
没想到这个过程还挺曲折复杂的,在这里记录一下过程用到的各种反反爬的技巧。
主要使用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()
嗯,这样效果好多了。
被查封的概率小了很多,然而——效率也贼低(它竟然偶尔还说我频率过高然后要我验证……再慢就属于老年人的浏览速度了啊摔)。
好吧,至少我现在可以一边自己看着词条,一边让它一直爬了。
好像还有点快乐?哈哈哈。