爬虫总结(五)-- 其他技巧

补充前面没有提到的一些技巧。

模拟登录

研究源码

以 github 登录(https://github.com/login) 为例,查看html源码会发现表单里面有个隐藏的authenticity_token值,这个是需要先获取然后跟用户名和密码一起提交的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<div class="auth-form p-3" id="login">
<!-- </textarea> --><!-- '"` --><form accept-charset="UTF-8" action="/session" data-form-nonce="b2e0b5f779ddbb5dbf93b903a82e5fc5204da96b" method="post"><div style="margin:0;padding:0;display:inline"><input name="utf8" type="hidden" value="&#x2713;" /><input name="authenticity_token" type="hidden" value="MDOLdxNeNMPn2sjrj51G+v/yMYpikLru8QWiLI170WRME4UBfvGItiAhzZWFujZVUSoT7SFygFcjE8pMfRcMHQ==" /></div> <div class="auth-form-header">
<h1>Sign in to GitHub</h1>
</div>
<div id="js-flash-container">
</div>
<div class="auth-form-body mt-4">
<label for="login_field">
Username or email address
</label>
<input autocapitalize="off" autocorrect="off" autofocus="autofocus" class="form-control input-block" id="login_field" name="login" tabindex="1" type="text" />
<label for="password">
Password <a href="/password_reset" class="label-link">Forgot password?</a>
</label>
<input class="form-control form-control input-block" id="password" name="password" tabindex="2" type="password" />
<input class="btn btn-primary btn-block" data-disable-with="Signing in…" name="commit" tabindex="3" type="submit" value="Sign in" />
</div>
</form>

重写start_requests方法

首先确保 cookie 打开

COOKIES_ENABLES = True

重写start_requests方法

# 重写了爬虫类的方法, 实现了自定义请求, 运行成功后会调用callback回调函数
def start_requests(self):
    return [Request("https://github.com/login",
                    meta={'cookiejar': 1}, callback=self.post_login)]

# FormRequeset
def post_login(self, response):
    # 先去拿隐藏的表单参数authenticity_token
    authenticity_token = response.xpath(
        '//input[@name="authenticity_token"]/@value').extract_first()
    logging.info('authenticity_token=' + authenticity_token)
    pass

start_requests方法指定了回调函数,用来获取隐藏表单值authenticity_token,同时我们还给Request指定了cookiejar的元数据,用来往回调函数传递cookie标识。

使用FormRequest

Scrapy为我们准备了FormRequest类专门用来进行Form表单提交的。

# FormRequeset
def post_login(self, response):
    # 先去拿隐藏的表单参数authenticity_token
    authenticity_token = response.xpath(
        '//input[@name="authenticity_token"]/@value').extract_first()
    logging.info('authenticity_token=' + authenticity_token)
    # FormRequeset.from_response是Scrapy提供的一个函数, 用于post表单
    # 登陆成功后, 会调用after_login回调函数,如果url跟Request页面的一样就省略掉
    return [FormRequest.from_response(response,
                                      url='https://github.com/session',
                                      meta={'cookiejar': response.meta['cookiejar']},
                                      #headers=self.post_headers,  
                                      formdata={
                                          'login': 'shuang0420',
                                          'password': 'XXXXXXXXXXXXXXXXX',
                                          'authenticity_token': authenticity_token
                                      },
                                      callback=self.after_login,
                                      dont_filter=True
                                      )]

FormRequest.from_response()方法让你指定提交的url,请求头还有form表单值,注意我们还通过meta传递了cookie标识。它同样有个回调函数,登录成功后调用。下面我们来实现它。注意这里我继续传递cookiejar,访问初始页面时带上cookie信息。

def after_login(self, response):
    # 登录之后,开始进入我要爬取的私信页面
    for url in self.start_urls:
        logging.info('letter url=' + url)
        yield Request(url, meta={'cookiejar': response.meta['cookiejar']},callback=self.parse_page)

页面处理

这个例子的主要任务是模拟登录,在登录 github 后爬取主页的 comments 内容。

代码

1
2
3
4
5
6
7
8
9
10
11
12
def parse_page(self, response):
"""comments 内容"""
logging.info(u'--------------消息分割线-----------------')
logging.info(response.url)
replaceTags = re.compile('<.*?>')
replaceLine = re.compile('\r|\n|\t')
message = response.xpath(
'//div[@class="details"]/div[@class="message markdown-body"]|div[@class="message markdown-body"]/blockquote').extract()
for m in message:
m = replaceTags.sub("", m)
m = replaceLine.sub("", m)
print m

爬取结果

I like topn (or perhaps top_n) a little better, because it's not dependent on what the features represent (words, phrases, entities, characters...). …      
Note: as of now, the classes and methods are not well arranged, and there are a few mock classes (which will be removed) to help me with testing. O…      
Hello @gojomo thank you for replying fast.I have used save() to save the model and load_word2vec_format() to load the model. Thats where the probl…      
The unicode_errors='ignore' option should make it impossible for the exact same error to occur; perhaps you're getting some other very-similar error?       
(Nevermind, #758 added annoy.)      
It looks like the tests don't run on Travis, since Annoy is not installed there. Not sure how to fix the test failure in Python 2.6 either.      
Hello,Sorry for posting after even you have created the FAQ.I trained a model with tweets which had some undecodable unicode characters. When i t…      
dtto      
Misleading comment: there is no "training", the model is transferred from Mallet. These parameters only affect inference, model is unchanged.      
PEP8: Hanging indent of 4 spaces.      
@piskvorky I've addressed the comments. Could you please check?      
Thanks, that was quick :)      
@piskvorky , @tmylk , could you review?      
Added comment, made change in changelog.       
No, this was after that in 0.13.2. I noticed it because when I was testing the #768 solution, print_topics was failing.      
@tmylk how do you review these PRs before merging? There are too many errors, we cannot merge code so carelessly.      
Looks good to me... except still needs a comment explaining why the alias is there. And maybe a mention in the changelog, so we can deprecate the o…      
Yes, assign self.wordtopics = self.word_topics, with a big fat comment explaining why this alias is there.      
I don't understand how this version with storing unicode to binary files even worked. It means our unit tests must be faulty / incomplete.     

代码

识别验证码

验证码是一种非常有效的反爬虫机制,它能阻止大部分的暴力抓取,在电商类、投票类以及社交类等网站上应用广泛。如果破解验证码,成为了数据抓取工作者必须要面对的问题。下面介绍3种常用的方法。

更换ip地址

在访问某些网站时,我们最初只是需要提供用户名密码就可以登陆的,比如说豆瓣网,如果我们要是频繁登陆访问,可能这时网站就会出现一个验证码图片,要求我们输入验证码才能登陆,这样在保证用户方便访问的同时,又防止了机器的恶意频繁访问。对于这种情况,我们可以使用代理服务器访问,只需要换个ip地址再次访问,验证码就不会出现了,当然,当验证码再次出现的时候,我们只能再更换ip地址。

使用cookie登陆

如果采用cookie登陆,可以这样实现:首先需要手动登陆网站一次,获取服务器返回的cookie,这里就带有了用户的登陆信息,当然也可以采用获取的cookie登陆该网站的其他页面,而不用再次登陆。具体代码已经实现,详见ZhihuSpider。我们只需要在配置文件中提供用户名密码,及相应的cookie即可。对于不出现验证码的情况,爬虫会提交用户名密码实现post请求登陆,如果失败,才会使用事先提供的cookie信息。

需要说明的是,判断爬虫登陆与否,我们只需要看一下爬取的信息里面是否带有用户信息即可。在使用cookie登陆的时候,还需要不定期更新cookie,以保证爬取顺利进行。

验证码识别手段

使用cookie登陆比较简单,但是有时效性问题。验证码识别是个很好的思路,然而识别的精度又限制了抓取的效率。

爬取js交互式表格数据

这里,若使用Google Chrome分析”请求“对应的链接(方法:右键→审查元素→Network→清空,点击”加载更多“,出现对应的GET链接寻找Type为text/html的,点击,查看get参数或者复制Request URL),循环过程。

启动 splash 容器

$ docker run -p 8050:8050 scrapinghub/splash

配置 scrapy-splash

在你的 scrapy 工程的配置文件settings.py中添加

SPLASH_URL = 'http://192.168.59.103:8050'

# 添加Splash中间件,还是在settings.py中通过DOWNLOADER_MIDDLEWARES指定,并且修改HttpCompressionMiddleware的优先级
DOWNLOADER_MIDDLEWARES = {
    'scrapy_splash.SplashCookiesMiddleware': 723,
    'scrapy_splash.SplashMiddleware': 725,
    'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 810,
}

# 默认情况下,HttpProxyMiddleware的优先级是750,要把它放在Splash中间件后面

# 设置Splash自己的去重过滤器
DUPEFILTER_CLASS = 'scrapy_splash.SplashAwareDupeFilter'

# 如果你使用Splash的Http缓存,那么还要指定一个自定义的缓存后台存储介质,scrapy-splash提供了一个scrapy.contrib.httpcache.FilesystemCacheStorage的子类

HTTPCACHE_STORAGE = 'scrapy_splash.SplashAwareFSCacheStorage'

# 如果你要使用其他的缓存存储,那么需要继承这个类并且将所有的scrapy.util.request.request_fingerprint调用替换成scrapy_splash.splash_request_fingerprint

使用 scrapy-splash

SplashRequest

最简单的渲染请求的方式是使用scrapy_splash.SplashRequest,通常你应该选择使用这个

1
2
3
4
5
6
7
8
9
10
11
12
13
yield SplashRequest(url, self.parse_result,
args={
# optional; parameters passed to Splash HTTP API
'wait': 0.5,
# 'url' is prefilled from request url
# 'http_method' is set to 'POST' for POST requests
# 'body' is set to request body for POST requests
},
endpoint='render.json', # optional; default is render.html
splash_url='<url>', # optional; overrides SPLASH_URL
slot_policy=scrapy_splash.SlotPolicy.PER_DOMAIN, # optional
)

另外,你还可以在普通的scrapy请求中传递splash请求meta关键字达到同样的效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
yield scrapy.Request(url, self.parse_result, meta={
'splash': {
'args': {
# set rendering arguments here
'html': 1,
'png': 1,
# 'url' is prefilled from request url
# 'http_method' is set to 'POST' for POST requests
# 'body' is set to request body for POST requests
},
# optional parameters
'endpoint': 'render.json', # optional; default is render.json
'splash_url': '<url>', # optional; overrides SPLASH_URL
'slot_policy': scrapy_splash.SlotPolicy.PER_DOMAIN,
'splash_headers': {}, # optional; a dict with headers sent to Splash
'dont_process_response': True, # optional, default is False
'dont_send_headers': True, # optional, default is False
'magic_response': False, # optional, default is True
}
})

Splash API说明,使用SplashRequest是一个非常便利的工具来填充request.meta[‘splash’]里的数据

  • meta[‘splash’][‘args’] 包含了发往Splash的参数。
  • meta[‘splash’][‘endpoint’] 指定了Splash所使用的endpoint,默认是render.html
  • meta[‘splash’][‘splash_url’] 覆盖了settings.py文件中配置的Splash URL
  • meta[‘splash’][‘splash_headers’] 运行你增加或修改发往Splash服务器的HTTP头部信息,注意这个不是修改发往远程web站点的HTTP头部
  • meta[‘splash’][‘dont_send_headers’] 如果你不想传递headers给Splash,将它设置成True
  • meta[‘splash’][‘slot_policy’] 让你自定义Splash请求的同步设置
  • meta[‘splash’][‘dont_process_response’] 当你设置成True后,SplashMiddleware不会修改默认的scrapy.Response请求。默认是会返回SplashResponse子类响应比如SplashTextResponse
  • meta[‘splash’][‘magic_response’] 默认为True,Splash会自动设置Response的一些属性,比如response.headers,response.body等
    如果你想通过Splash来提交Form请求,可以使用scrapy_splash.SplashFormRequest,它跟SplashRequest使用是一样的。

Responses

对于不同的Splash请求,scrapy-splash返回不同的Response子类

  • SplashResponse 二进制响应,比如对/render.png的响应
  • SplashTextResponse 文本响应,比如对/render.html的响应
  • SplashJsonResponse JSON响应,比如对/render.json或使用Lua脚本的/execute的响应

如果你只想使用标准的Response对象,就设置meta[‘splash’][‘dont_process_response’]=True

所有这些Response会把response.url设置成原始请求URL(也就是你要渲染的页面URL),而不是Splash endpoint的URL地址。实际地址通过response.real_url得到

实例

爬取华为应用市场( http://appstore.huawei.com/more/all )的“下一页” url 链接。

查看网页源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script type="text/javascript" src="http://app.vmall.com/js/core/jquery.min.js"></script>
<script type="text/javascript">
var jsResource = new Array();
jsResource['cloud.page.count'] = "共";
jsResource['cloud.page.numbers'] = "条记录";
jsResource['cloud.page.last_page'] = "上一页";
jsResource['cloud.page.next_page'] = "下一页";
jsResource['cloud.page.pages'] = "页";
jsResource['cloud.page.first'] = "首页";
jsResource['cloud.page.last'] = "尾页";
jsResource['cloud.downAppError']="您的请求正在处理中,请不要重复提交。"
jsResource['cloud.msg.ok']="确定"
jsResource['cloud.msg.message']="提示"
jsResource['cloud.detail.close']="关闭"
</script>
<script type="text/javascript" src="http://app.vmall.com/js/all/more.js?version=2.9.5.20150418"></script>

查看渲染后的代码

启动 splash 容器,在浏览器打开 http://192.168.59.103:8050/ , 输入网址进行 render,查看渲染后的代码。

1
<div class="page-ctrl ctrl-app" id="recommendListPage"><a href="http://appstore.huawei.com:80/more/all/1">首页</a> <a href="http://appstore.huawei.com:80/more/all/1"><em class="arrow-grey-lt">&nbsp;</em>上一页</a> <a href="http://appstore.huawei.com:80/more/all/1">1</a><span>2</span> <a href="http://appstore.huawei.com:80/more/all/3">3</a> <a href="http://appstore.huawei.com:80/more/all/4">4</a> <a href="http://appstore.huawei.com:80/more/all/5">5</a> <a href="http://appstore.huawei.com:80/more/all/3">下一页<em class="arrow-grey-rt">&nbsp;</em></a> <a href="http://appstore.huawei.com:80/more/all/41">尾页</a>

spider 部分代码

def parse(self, response):
    page = Selector(response)
    hrefs = page.xpath('//h4[@class="title"]/a/@href')
    if not hrefs:
        return
    for href in hrefs:
        url = href.extract()
        yield scrapy.Request(url, callback=self.parse_item)
# find next page
    nextpage = page.xpath('//div[@class="page-ctrl ctrl-app"]/a/em[@class="arrow-grey-rt"]/../@href').extract_first()
    print nextpage
    yield scrapy.Request(nextpage,callback=self.parse,meta={
    'splash': {
    'endpoint': 'render.html',
    'args': {'wait': 0.5}
    }
    })

完整代码

分析不规则的 html

之前的几个部分解决的都是 下载 Web 页面 的问题,这里补充下获取网页后分析过程的一些技巧。
以苏宁易购 help 页面为例。start_url 是 http://help.suning.com/faq/list.htm , 爬取的是左边侧栏每个大类的每个小类下右边的问题页面,如“权益介绍”、“等级权益介绍”这些 FAQ 页面,如何到达这些页面就不再多说,关键是到达这些页面后怎么获得信息。

看一部分的网页源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<div id="contentShow">
<p class="MsoNormal" style="background:white;text-align:left;" align="left">
<span style="font-size:9pt;font-family:宋体;color:black;"></span>
</p>
<p class="MsoNormal" style="background:white;" align="left">
<br />
</p>
<p class="MsoNormal" style="background:white;" align="left">
<b>一、权益类型</b><b></b>
</p>
<p class="MsoNormal" style="background:white;" align="left">
本次改版将上线<span>7</span>个会员权益,涵盖价格优惠、资格抢先、服务优先等多个方面,会员等级越高,可享受到的会员权益越多。<span></span>
</p>
<p class="MsoNormal" style="background:white;" align="left">
<b>二、具体详情:</b><b></b>
</p>
<p class="MsoNormal" style="background:white;" align="left">
<b>1</b><b>、生日红包</b><br />
特权内容:<span><br />
</span>已验证手机号的<span>V2</span>及以上等级的会员,在实名认证或完善生日资料后,可在生日周期间获得生日红包。<span><br />
</span><span>V2</span>等级生日红包为<span>6</span>元云券,<span>V3</span>等级生日红包为<span>8</span>元云券。(<span>2016</span>年6月12日开始实施)<span><br />
</span>注意事项:<span><br />
1</span>)生日红包券为限品类云券,在生日周时自动发到会员账户,会员在成功收到生日红包券后会有短信提醒,并可登录“我的易购<span>-</span>我的优惠券”【<span><a href="http://member.suning.com/emall/MyGiftTicket" target="_blank"><span>点击查看</span></a></span>】,每个会员同一自然年内仅可获得一张生日红包券;<span><br />
2</span>)券使用规则:且限一次性使用、不找零、不兑现,不可以和云券叠加,可以和无敌券、易券叠加使用,不可使用自提;<span><br />
3</span>)券有效期:自券到账之日起<span>8</span>日内有效;<span><br />
4</span>)券适用商品范围:仅限购买自营商品使用,也可以用于大聚惠、抢团购、手机专享价、名品特卖商品,但闪拍、秒杀、预售、海外购、虚拟商品、特殊类商品(一段奶粉等)及平台商户商品不可使用;<span><br />
5</span>)使用生日红包券的订单若发生退货,在有效期内券将返回至顾客账户,可再次使用;如用券订单退货时已超过券有效期,券将自动失效,不做延期;<span></span>
</p>
......

不难发现,有些文字分布在

  • div[@id=”contentShow”]/p
  • div[@id=”contentShow”]/p/span
  • div[@id=”contentShow”]/p/b

观察其他页面会发现还有些分布在 div[@id=”contentShow”]/h4 下或者 h3 下,有的甚至直接就在 div[@id=”contentShow”] 下。。
怎么办?
当然可以穷尽各种规则,也可以先把不需要的标签给去掉再 extract,这些我开始都傻傻的尝试过,结果总会忽略一些文字,后来在沮丧的看着 output 文件时福至心灵,直接取了 div[@id=”contentShow”] 再把所有的标签去掉不就行了?!

上代码

1
2
3
4
5
page = html.xpath('//div[@id="contentShow"]').extract_first()
replaceTags = re.compile('<.*?>')
replaceLine = re.compile('\r|\n|\t')
page = replaceTags.sub("", page)
page = re.sub(replaceLine, "", page)

最后的结果非常干净

1
{"url": "http://help.suning.com/page/id-26.htm", "text": " 一、账号注册目前注册个人用户仅支持:手机号方式进行注册。1、打开苏宁易购网站,点击页头“注册”,进入注册页面 2、进入注册页面,如果您是个人用户,可以用手机号进行注册;如果您是企业用户,可以点击“企业用户注册”,用单位名称进行注册,如果您有易购账号,可以点击“马上登录”3、填写注册信息,按照网页提示,填写手机号、验证码和密码 4、恭喜您,注册成功 ", "question": "账户注册", "title": "易购注册登录"}

掌握这个技巧,处理类似问题就很简单啦,如再爬京东的 help 网页,稍微改下代码5分钟就能搞定。

代码

其他

回头谈点背景知识,scrapy使用了twisted.一个异步网络框架.因此要留意潜在的阻塞情况.但注意到settings中有个参数是设置ItemPipeline的并行度.由此推测pipeline不会阻塞,pipeline可能是在线程池中执行的(未验证).Pipeline一般用于将抓取到的信息保存(写数据库,写文件),因此这里你就不用担心耗时操作会阻塞整个框架了,也就不用在Pipeline中将这个写操作实现为异步.
除此之外框架的其他部分.都是异步的,简单说来就是,爬虫生成的请求交由调度器去下载,然后爬虫继续执行.调度器完成下载后会将响应交由爬虫解析.
网上找到的参考例子,部分将js支持写到了DownloaderMiddleware中,scrapy官网的code snippet也是这样 .若这样实现,就阻塞了整个框架,爬虫的工作模式变成了,下载-解析-下载-解析,而不在是并行的下载.在对效率要求不高的小规模爬取中问题不大.
更好的做法是将js支持写到scrapy的downloader里.网上有一个这样的实现(使用selenium+phantomjs).不过仅支持get请求.
在适配一个webkit给scrapy的downloader时,有各种细节需要处理.

参考链接

scrapy定制爬虫-爬取javascript内容

Scrapy笔记(11)- 模拟登录

网络爬虫-验证码登陆

Scrapy笔记(12)- 抓取动态网站

徐阿衡 wechat
欢迎关注:徐阿衡的微信公众号
客官,打个赏呗~