python 爬虫实录

本项目仓库

爬虫这个词想必我们都不陌生,但它究竟是如何实现的?就让小编带大家看看吧!

网络请求

当我们访问一个网页的时候,实际上是浏览器向对应网址解析后的 ip 的服务器发送了一些请求,然后通过获取的回复来构建出的页面。

网页构成

了解一些 html,css,js 相关知识即可

而对于爬虫而言,还要了解一下 dom tree 和 xpath 等等,正则表达式也可

这样一来,我们的爬虫的思路就清晰了。我们可以在 python 程序中也向服务器发送请求,再根据响应来寻找我们需要的数据即可。而寻找数据则需要先人工分析对应网站的页面的 html 结构,找到需要的数据所在的位置。因此实际上爬虫大部分算是体力活。

实战

CSDN

由于我们要爬取的是 QA 问答对的形式,并且为了保证质量,所以我们进入 CSDN 搜索关键字后,点击下方的问答并选中已采纳。接下来我们可以看到下面列出了若干个问题和最佳答案。我们点开控制台,进入 Network 项并四处翻找,可能会看到一个 search? q开头的包。进入 response 项,我们便能看到服务器给我们的响应了。可以看到这是一个 json 文件,是一个字典。再在里面翻找,我们会发现在result_vos 这项(也是一个字典)中有我们需要的更多的信息,比如单个问答的 url。复制打开后,我们会发现我们进入了单个问答的网页。因此,我们的思路就逐渐明确了。首先进入搜索后的页面得到刚刚这个包,再在响应中找到每个问答的网页,再进去继续获取具体的信息。

在 python 程序中如果我们发送和刚刚这个包一样的请求,得到的响应便也是我们看到的那样。在哪里看发送的请求长啥样呢?我们进入刚刚那个包的 Headers 项便能看到了。一点进去,在 General 出就有一个 Request URL,这个便是我们需要发送的请求。

接下来,我们再在单个问答的具体网页中获取问题和答案。再次进入控制台,在 Elements 项中找到点击标题,问题,答案等,可以发现它们在 html 中的位置。再在 Network 项中找到一串数字开头的包,发现其响应中就是我们的页面。我们在程序中获取这个响应,并以此构建 dom tree。此时就要用到 xpath 相关的知识了。我们刚刚在 Elements 页面探索了一番后,可以写出对应的 xpath,比如

title = tree.xpath("//section[@class='title-box']/h1/text()")[0]
question = tree.xpath('//section[@class="question_show_box"]//div[@class="md_content_show"]//text()')
answer = tree.xpath('//section[@div="@class=answer_box"]//div[@class="md_content_show"]//text()')

就是标题,问题和答案描述的 xpath。于是,对于这一个问答网页,我们应该就能通过程序获取对应的问题和答案了。python,启动!

这里值得补充的一点是,在控制台的 Element 项中右键点击某个元素其实可以直接复制对应的 xpath,不过它是从根一个个节点一路下来的,可能会比较繁琐,而且遇到 tbody 这种还会出错,具体原因后面细说。

当我们启动程序后,我们大概会发现,title 和 question 确实都被爬下来了,但是 answer 却啥都没有。是我们的 xpath 写错了吗?检查几遍后发现没有。这时事情便变得奇怪起来。

让我们回顾一下我们刚刚爬虫的过程,这时细心的读者可能会发现,在第一次进入搜索到的页面的时候,我们是在 Network 的 response 中寻找需要的数据的,但是第二次却直接在 Elements 里面去找了。实际上这是作者在写爬虫的时候犯的一个错误。那么当我们尝试在第二次的具体问答页面的 response 中寻找时,我们会发现,问题和标题的确没什么区别,但是答案部分却不见了。而当我们尝试 Ctrl+F搜索答案中的某些字时,会发现它们是被套在一个 script 块内的函数中的,这意味着它是被动态渲染出来的。这样一来,我们便不能直接在收到的包中找到它。

此时便出现了僵局,一种通用的解决方法是使用 selenuim 或者类似的库,去模拟一个浏览器出来,先渲染一波,再在生成的页面去找。但对于 CSDN,还有一种更简单的方法。

我们回忆起,在刚搜到关键词,显示许多问答的页面时,每个问答的最佳答案是已经显示出来了的。此时,当我们回过头再去翻一翻那个页面获得的包,会惊讶地发现,在 result_vos对应的字典内,有关键字 answer 已经对应了最佳回答的全部文本。也就是说,我们可以在这个页面就获得答案。但可惜的是,这个页面的问题描述是显示不全的,所以依旧需要进入具体页面去爬取问题的标题和具体描述。

最后再总结一下我们爬取 CSDN 的思路:首先进入 CSDN 搜索关键词,进入到显示许多问答的页面,在这个页面获得每个问答的答案和显示单个问答的页面的 url,再进入到单个问答的页面获得问题描述。对于我们的程序,则是通过发送两次请求完成。

而我们要爬取的关键字自然不止一个。仔细观察搜索之后的页面的 url,可以发现在 q=后面的就是我们的关键字。于是我们可以先列出要爬的关键字列表,再用 python 的 .format 替换 url 中 q= 后面的部分。

最后是一个小小的细节:在第一次进入的页面中,一个收到的包里的问答是不全的。具体来说,当我们下拉网页并一边观察 Network 项中的 search?q= 开头的包,我们会发现这样的包会不断增加。也就是说,网页中显示的问答变多,实际上是发送了更多的请求获得了更多的包,以包含更多的问答。那怎么把这些包全部爬下来呢?我们选中不同的包,在 Payload 项中可以看到,它们的区别在于 p= 后面的数字不同。因此,对于一个关键词,我们改变 p=后面的数字,多发一些请求,就能把包都收到了。至此,我们爬取 CSDN 的过程就结束了。

wikipedia

首先,我们进入 https://zh.wikipedia.org/wiki/ 页面,在搜索框输入关键字跳转。多搜几个关键字后,可以发现情况有两种:一种是输入关键字后直接进入了一个具体的词条的页面,比如搜索 斐波那契数;另一种则是进入的页面中列举了许多词条,需要我们进一步点击进入对应词条的页面,比如 大O表示法。对于第一种情况,我们直接进一步处理即可;而对于第二种,我目前采取的策略则是选择进入搜索出来的第一个词条再进一步处理。找到并进入第一个词条的方法则比较简单,分析网页结构再发送一次请求即可,想必在爬完 CSDN 后这已经不成问题。当然,这样的缺陷是可能第一个词条实际上和我们要搜索的东西毫不相关,也可能后面有更多的的词条的相关性更大。这则一方面是我们搜索的关键字的问题,另一方面,我们也可以考虑选择进入更多的词条再进一步处理(先把数据爬下来再说)。

而进入一个词条的页面后, wikipedia 上本没有问答的形式,因此我们需要手动将其设置为问答的形式。具体来说,比如我们来到了 素性测试 的页面,那么可以将“什么是素性测试?”作为问题,对应的介绍作为答案。通过观察,可以发现 wikipedia 的页面由若干级标题构成,有一个页面的大标题(h1),和各部分的小标题 (h2,h3)等。对应标题下方则是具体介绍。那么我们的问题可以设计成类似于“什么是h1的h2的h3?”的形式,再在相邻的标题间寻找答案即可。

本以为事情将会非常简单地解决,可没想到,wikipedia 的页面远比我想象的复杂nt。最初,一个简单的思路是把各级标题的位置找到,这很好办,寻找 @class='mw-headline'即可。然后把两个标题之间的文本爬下来作为答案。一个来自于 lpr 同学的类似的思路则是利用 wikipedia 标题旁边的 编辑 字来找,并且可以通过其链接跳转到的页面获取文本,也非常方便。

但当我打开斐波那契数的页面的时候,这个方法便出现了问题。原因是在 h3 标题初等代数解法下的各个步骤中,还用到了 h4 标题来表示步骤中的每一步。但实际上,问题到 初等代数解法 应该就已经需要作为一个最小的单位了。也就是说,按照上面的方法,我们会把 h4 标题首先构建等比数列单独作为一个问题爬下来,但实际上它应该是 初等代数解法中的一步。至此,一个问题是如何将标题区分开来,它究竟是一个问题,还是只是某一个步骤?

通过标题的等级来区分的办法并行不通。比如 堆栈 的页面中,h4 依旧是作为一个独立的问题存在的。此时似乎陷入了僵局。但当我们对比 斐波那契数堆栈 的页面时,或许会观察到,它们的最上方的目录的显示似乎有区别。斐波那契数的目录到 初等代数解法 后就是最后一级了,并没有包含 h4 的标题,而堆栈的目录中则是也列出了那些 h4 标题。此时,便自然能得出根据目录来寻找问题的想法。而在控制台模式中,仔细观察后,会发现目录框可以通过 div[@id='toc']获得。而在堆栈的页面中,它的上一级是 div[@class="mw-parser-output"],但在斐波那契数的页面中,二者之间还夹了一个 div[@class="toclimit-3"]。根据字面意思,有理由怀疑这个标签是用来限制目录大小的。而将其中的 3 改成 4 后,果然本来没有显示的 h4 标题也显示在目录中了。至此,通过这个标签的有无和其中的数字,我们便可以获得目录中的所有关键词,也就是一个问题的最小单位。

此时还有一个小细节:如果我们获取的是目录中的文本内容的话,依旧会得到 斐波那契数页面的制裁。它的目录中有一个 模n的周期性的关键字,而在 html 的中,却变成了模<i>n</i>的周期性。因此使用 text() 的话,则会被拆成三个,这也不好区分了。此时再一次陷入僵局。再次观察目录的成分后,发现上方还有一个 <a href="#模n的週期性">。在 href 属性中这个词是完整的。因此,我们获取这里的词即可,而这写成 xpath 就是//div[@id='toc']/ul/li/a/@href。当然,在后面的处理中还要去掉最前面的 #

在将我们需要的标题的关键词都找到后,便可以去寻找对应的答案了。这里我的想法是,找到下一个和它同级的标题,再把中间的文本都爬下来作为答案。这样做的一个好处是,对于 h2 ,它的回答便能包括它的介绍里面的所有 h3 等更低级的标题。当然,这里要特判一下处于最后的情况,比如一个 h2 下的最后一个 h3 ,它后面可能是另一个 h2,此时就不能找下一个 h3 了,而是下面的 h2,或者直接到了页面的结尾。

本以为对 wikipedia 的爬取就到此为止了,然而,在爬取的过程中,却又出现了一些神奇的页面。某个页面的 h2 标题下紧接着的是 h4 而非 h3,因此判断标题的时候不能直接判断相邻的等级,而需要考虑所有的。另外一个特殊情况是,在 编辑距离 页面中,压根没有目录。这时就要使用最开始的方法直接把所有标题爬下来处理了。

并行爬虫

参考 https://cuiqingcai.com/202271.html

在我们之前的爬虫过程中,会发现,搜索许多关键字的话,爬虫的运行时间将非常久。有没有优化的方法呢?自然是有的。我们的爬虫在发送请求后,需要等待服务器的回复。而在我们的朴素爬虫中,我们只能在那干等。而并行爬虫的基本原理就是在等待响应的时候去做别的事情,比如发送其他的请求。这样,如果一个网站必须在 5 秒后返回响应,那么我们 10 个请求本来需要等待 50 秒,但并行化后可以直接发出 10 个请求,在等待 5 秒后,所有的请求都得到了响应。

而这一点可以基于 python 的 asyncio 库通过协程实现。上面的参考链接中其实已经讲的很清楚了,这里就不再赘述。

赞赏