整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

如何让Python爬虫一天抓取100万张网页

文的文字及图片来源于网络,仅供学习、交流使用,不具有任何商业用途,版权归原作者所有,如有问题请及时联系我们以作处理。

作者: 王平

源自:猿人学Python

PS:如有需要Python学习资料的小伙伴可以加点击下方链接自行获取

http://note.youdao.com/noteshare?id=3054cce4add8a909e784ad934f956cef

前一两年抓过某工商信息网站,几三周时间大约抓了过千万多万张页面。那时由于公司没啥经费,报销又拖得很久,不想花钱在很多机器和带宽上,所以当时花了较多精力研究如何让一台爬虫机器达到抓取极限。

本篇偏爬虫技术细节,先周知。

Python爬虫这两年貌似成为了一项必备技能,无论是搞技术的,做产品的,数据分析的,金融的,初创公司做冷启动的,都想去抓点数据回来玩玩。这里面绝大多数一共都只抓几万或几十万条数据,这个数量级其实大可不必写爬虫,使用 chrome 插件 web scraper 或者让 selenium 驱动 chrome 就好了,会为你节省很多分析网页结构或研究如何登陆的时间。

本篇只关注如何让爬虫的抓取性能最大化上,没有使用scrapy等爬虫框架,就是多线程+Python requests库搞定。

对一个网站定向抓取几十万张页面一般只用解决访问频率限制问题就好了。对机器内存,硬盘空间,URL去重,网络性能,抓取间隙时间调优一般都不会在意。如果要设计一个单台每天抓取上百万张网页,共有一亿张页面的网站时,访问频率限制问题就不是最棘手的问题了,上述每一项都要很好解决才行。硬盘存储,内存,网络性能等问题我们一项项来拆解。

一、优化硬盘存储

所以千万级网页的抓取是需要先设计的,先来做一个计算题。共要抓取一亿张页面,一般一张网页的大小是400KB左右,一亿张网页就是1亿X200KB=36TB 。这么大的存储需求,一般的电脑和硬盘都是没法存储的。所以肯定要对网页做压缩后存储,可以用zlib压缩,也可以用压缩率更好的bz2或pylzma 。

但是这样还不够,我们拿天眼查的网页来举例。天眼查一张公司详情页的大小是700KB 。

对这张网页zlib压缩后是100KB。

一亿个100KB(9TB)还是太大,要对网页特殊处理一下,可以把网页的头和尾都去掉,只要body部分再压缩。因为一张html页面里<head></head>和<footer></footer>大都是公共的头尾信息和js/css代码,对你以后做正文内容抽取不会影响(也可以以后做内容抽取时把头尾信息补回去就好)。

来看一下去掉头尾后的html页面大小是300KB,压缩后是47KB。

一亿张就是4T,差不多算是能接受了。京东上一个4T硬盘600多元。

二、优化内存,URL去重

再来说内存占用问题,做爬虫程序为了防止重复抓取URL,一般要把URL都加载进内存里,放在set()里面。拿天眼查的URL举例:

https://www.tianyancha.com/company/23402373

这个完整URL有44个字节,一亿个URL就是4G,一亿个URL就要占用4G内存,这还没有算存这一亿个URL需要的数据结构内存,还有待抓取URL,已抓取URL还保存在内存中的html等等消耗的内存。

所以这样直接用set()保存URL是不建议的,除非你的内存有十几个G。

一个取巧的办法是截断URL。只把URL:

https://www.tianyancha.com/company/23402373

的后缀:23402373放进set()里,23402373只占8个字节,一亿个URL占700多M内存。

但是如果你是用的野云主机,用来不断拨号用的非正规云主机,这700多M内存也是吃不消的,机器会非常卡。

就还需要想办法压缩URL的内存占用,可以使用BloomFilter算法,是一个很经典的算法,非常适用海量数据的排重过滤,占用极少的内存,查询效率也非常的高。它的原理是把一个字符串映射到一个bit上,刚才23402373占8个字节,现在只占用1个bit(1字节=8bit),内存节省了近64倍,以前700M内存,现在只需要10多M了。

BloomFilter调用也非常简单,当然需要先install 安装bloom_filter:

from bloom_filter import BloomFilter # 生成一个装1亿大小的
bloombloom = BloomFilter(max_elements=100000000, error_rate=0.1)
 # 向bloom添加URL bloom.add('https://www.tianyancha.com/company/23402373') #判断URL是否在bloombloom.__contains__('https://www.tianyancha.com/company/23402373')

不过奇怪,bloom里没有公有方法来判断URL是否重复,我用的contains()方法,也可能是我没用对,不过判重效果是一样的。

三、反抓取访问频率限制

单台机器,单个IP大家都明白,短时间内访问一个网站几十次后肯定会被屏蔽的。每个网站对IP的解封策略也不一样,有的1小时候后又能重新访问,有的要一天,有的要几个月去了。突破抓取频率限制有两种方式,一种是研究网站的反爬策略。有的网站不对列表页做频率控制,只对详情页控制。有的针对特定UA,referer,或者微信的H5页面的频率控制要弱很多。 另一种方式就是多IP抓取,多IP抓取又分IP代理池和adsl拨号两种,我这里说adsl拨号的方式,IP代理池相对于adsl来说,我觉得收费太贵了。要稳定大规模抓取肯定是要用付费的,一个月也就100多块钱。

adsl的特点是可以短时间内重新拨号切换IP,IP被禁止了重新拨号一下就可以了。这样你就可以开足马力疯狂抓取了,但是一天只有24小时合86400秒,要如何一天抓过百万网页,让网络性能最大化也是需要下一些功夫的,后面我再详说。

至于有哪些可以adsl拨号的野云主机,你在百度搜”vps adsl”,能选择的厂商很多的。大多宣称有百万级IP资源可拨号,我曾测试过一段时间,把每次拨号的IP记录下来,有真实二三十万IP的就算不错了。

选adsl的一个注意事项是,有的厂商拨号IP只能播出C段和D段IP,110(A段).132(B段).3(C段).2(D段),A和B段都不会变,靠C,D段IP高频次抓取对方网站,有可能对方网站把整个C/D段IP都封掉。

C/D段加一起255X255就是6万多个IP全都报废,所以要选拨号IP范围较宽的厂商。 你要问我哪家好,我也不知道,这些都是野云主机,质量和稳定性本就没那么好。只有多试一试,试的成本也不大,买一台玩玩一个月也就一百多元,还可以按天买。

上面我为什么说不用付费的IP代理池?

因为比adsl拨号贵很多,因为全速抓取时,一个反爬做得可以的网站10秒内就会封掉这个IP,所以10秒就要换一个IP,理想状况下一天86400秒,要换8640个IP。

如果用付费IP代理池的话,一个代理IP收费4分钱,8640个IP一天就要345元。 adsl拨号的主机一个月才100多元。

adsl拨号Python代码

怎么拨号厂商都会提供的,建议是用厂商提供的方式,这里只是示例:

windows下用os调用rasdial拨号:

import os # 拨号断开 
os.popen('rasdial 网络链接名称 /disconnect') # 拨号
os.popen('rasdial 网络链接名称 adsl账号 adsl密码')

linux下拨号:

import os # 拨号断开
code = os.system('ifdown 网络链接名称')# 拨号
code = os.system('ifup 网络链接名称')

四、网络性能,抓取技术细节调优

上面步骤做完了,每天能达到抓取五万网页的样子,要达到百万级规模,还需把网络性能和抓取技术细节调优。

1.调试开多少个线程,多长时间拨号切换IP一次最优。

每个网站对短时间内访问次数的屏蔽策略不一样,这需要实际测试,找出抓取效率最大化的时间点。先开一个线程,一直抓取到IP被屏蔽,记录下抓取耗时,总抓取次数,和成功抓取次数。 再开2个线程,重复上面步骤,记录抓取耗时,总的和成功的抓取次数。再开4个线程,重复上面步骤。整理成一个表格如下,下图是我抓天眼查时,统计抓取极限和细节调优的表格:

从上图比较可以看出,当有6个线程时,是比较好的情况。耗时6秒,成功抓取80-110次。虽然8个线程只耗时4秒,但是成功抓取次数已经在下降了。所以线程数可以设定为开6个。

开多少个线程调试出来了,那多久拨号一次呢?

从上面的图片看到,貌似每隔6秒拨号是一个不错的选择。可以这样做,但是我选了另一个度量单位,就是每总抓取120次就重新拨号。为什么这样选呢?从上图也能看到,基本抓到120次左右就会被屏蔽,每隔6秒拨号其实误差比较大,因为网络延迟等各种问题,导致6秒内可能抓100次,也可能抓120次。

2.requests请求优化

要优化requests.get(timeout=1.5)的超时时间,不设置超时的话,有可能get()请求会一直挂起等待。而且野云主机本身性能就不稳定,长时间不回请求很正常。如果要追求抓取效率,超时时间设置短一点,设置10秒超时完全没有意义。对于超时请求失败的,大不了以后再二次请求,也比设置10秒的抓取效率高很多。

3.优化adsl拨号等待时间

上面步骤已算把单台机器的抓取技术问题优化到一个高度了,还剩一个优化野云主机的问题。就是每次断开拨号后,要等待几秒钟再拨号,太短时间内再拨号有可能又拨到上一个IP,还有可能拨号失败,所以要等待6秒钟(测试值)。所以要把拨号代码改一下:

import os # 断开拨号
os.popen('rasdial 网络名称 /disconnect')
time.sleep(6) # 拨号 
os.popen('rasdial 网络名称 adsl账号名 adsl密码')

而且 os.popen(‘rasdial 网络名称 adsl账号名 adsl密码’) 拨号完成后,你还不能马上使用,那时外网还是不可用的,你需要检测一下外网是否联通。

我使用 ping 功能来检测外网连通性:

import os 
code = os.system('ping www.baidu.com')

code为0时表示联通,不为0时还要重新拨号。而ping也很耗时间的,一个ping命令会ping 4次,就要耗时4秒。

上面拨号等待6秒加上 ping 的4秒,消耗了10秒钟。上面猿人学Python说了,抓120次才用6秒,每拨号一次要消耗10秒,而且是每抓120次就要重拨号,想下这个时间太可惜了,每天8万多秒有一半时间都消耗在拨号上面了,但是也没办法。

当然好点的野云主机,除了上面说的IP范围的差异,就是拨号质量差异。好的拨号等待时间更短一点,拨号出错的概率要小一点。

通过上面我们可以轻松计算出一组抓取的耗时是6秒,拨号耗时10秒,总耗时16秒。一天86400秒,就是5400组抓取,上面说了一组抓取是120次。一天就可以抓取5400X120=64万张网页。

按照上述的设计就可以做到一天抓60多万张页面,如果你把adsl拨号耗时再优化一点,每次再节约2-3秒,就趋近于百万抓取量级了。

另外野云主机一个月才100多,很便宜,所以你可以再开一台adsl拨号主机,用两台一起抓取,一天就能抓一百多万张网页。几天时间就能镜像一个过千万网页的网站。

、唯一定律

无论有多少人共同参与同一项目,一定要确保每一行代码都像是唯一个人编写的。

二、HTML

2.1 语法

(1)用两个空格来代替制表符(tab) -- 这是唯一能保证在所有环境下获得一致展现的方法。

(2)嵌套元素应当缩进一次(即两个空格)。

(3)对于属性的定义,确保全部使用双引号,绝不要使用单引号。

(4)不要在自闭合(self-closing)元素的尾部添加斜线 -- HTML5 规范中明确说明这是可选的。

(5)不要省略可选的结束标签(closing tag)(例如,</li> 或 </body>)。

2.2 Example



三、HTML5 doctype

为每个 HTML 页面的第一行添加标准模式(standard mode)的声明,这样能够确保在每个浏览器中拥有一致的展现。



四、语言属性

根据 HTML5 规范:

强烈建议为 html 根元素指定 lang 属性,从而为文档设置正确的语言。这将有助于语音合成工具确定其所应该采用的发音,有助于翻译工具确定其翻译时所应遵守的规则等等。



五、IE 兼容模式

IE 支持通过特定的 <meta> 标签来确定绘制当前页面所应该采用的 IE 版本。除非有强烈的特殊需求,否则最好是设置为 edge mode,从而通知 IE 采用其所支持的最新的模式。



六、字符编码

通过明确声明字符编码,能够确保浏览器快速并容易的判断页面内容的渲染方式。这样做的好处是,可以避免在 HTML 中使用字符实体标记(character entity),从而全部与文档编码一致(一般采用 UTF-8 编码)。



七、引入 CSS 和 JavaScript 文件

根据 HTML5 规范,在引入 CSS 和 JavaScript 文件时一般不需要指定 type 属性,因为 text/css 和 text/javascript 分别是它们的默认值。



八、实用为王

尽量遵循 HTML 标准和语义,但是不要以牺牲实用性为代价。任何时候都要尽量使用最少的标签并保持最小的复杂度。

九、属性顺序

9.1 从大到小

HTML 属性应当按照以下给出的顺序依次排列,确保代码的易读性。

(1)class

(2)id, name

(3)data-*

(4)src, for, type, href, value

(5)title, alt

(6)role, aria-*

9.2 Example



9.3 说明

class 用于标识高度可复用组件,因此应该排在首位。id 用于标识具体组件,应当谨慎使用(例如,页面内的书签),因此排在第二位。

十、布尔(boolean)型属性

10.1 注意

(1)布尔型属性可以在声明时不赋值。XHTML 规范要求为其赋值,但是 HTML5 规范不需要。

(2)元素的布尔型属性如果有值,就是 true,如果没有值,就是 false。

(3)如果属性存在,其值必须是空字符串或 [...] 属性的规范名称,并且不要在首尾添加空白符。

简单来说,就是不用赋值。

10.2 Example



十一、减少标签的数量

编写 HTML 代码时,尽量避免多余的父元素。很多时候,这需要迭代和重构来实现。



十二、减少 JavaScript 生成的标签

通过 JavaScript 生成的标签让内容变得不易查找、编辑,并且降低性能。能避免时尽量避免。

最后,小编还给大家准备了web前端的学习资料

获取方式:请大家转发+关注并私信小编关键词:“资料”即可获取前端自学教程一套。

两天有个客户需要把网页转为pdf,之前也没开发过类似的工具,就在百度搜索了一波,主要有下面三种

  1. 在线转pdf
  2. 使用浏览器打印功能转pdf
  3. 使用本地软件工具转pdf

在线转pdf

在百度(我一般用必应)搜索“在线网页转pdf”就有很多可以做这个事的网站,免费的如

  • PDF24Tools

各种pdf的操作都有,免费使用,速度一般。

官网地址https://tools.pdf24.org/zh

PDF24 Tools

  • doctron

开源免费项目,使用golang写的,提供在线转

官网地址http://doctron.lampnick.com/

doctron在线体验demo

还有挺多其他的,可以自己搜索,但是都不符合我的预期。

使用浏览器打印功能转pdf

  1. 在浏览器右键,点击打印或者ctrl+p
  2. 在弹出的打印对话框中找到目标打印机选择“另存为PDF”
  3. 点击“保存”按钮即可下载pdf了

使用本地软件工具转pdf

Doctron,这是我今天要介绍的重头戏。

Doctron是基于Docker、无状态、简单、快速、高质量的文档转换服务。目前支持将html转为pdf、图片(使用chrome(Chromium)浏览器内核,保证转换质量)。支持PDF添加水印。

  • 使用chrome内核保证高质量将HTML转为pdf/图片。
  • 简易部署(提供docker镜像,Dockerfile以及k8s yaml配置文件)。支持丰富的转换参数。转为pdf和图片支持自定义大小。
  • 无状态服务支持。

管他的,先把代码下载下来再说

git clone https://gitcode.net/mirrors/lampnick/doctron.git

仓库

运行

go build
./doctron --config conf/default.yaml

运行截图

转pdf,访问http://127.0.0.1:8080/convert/html2pdf?u=doctron&p=lampnick&url=<url>,更换链接中的url为你需要转换的url即可。

转换效果

然后就可以写程序去批量转换需要的网页了,但是我需要转换的网页有两个需求

1、网站需要会员登录,不然只能看得到一部分

2、需要把网站的头和尾去掉的

这就为难我了,不会go语言啊,硬着头皮搞了,肯定有个地方打开这个url的,就去代码慢慢找,慢慢调试,功夫不负有心人,终于找到调用的地方了。

第一步:添加网站用户登录cookie

添加cookie之前

添加cookie之后

第二步:去掉网站头尾

chromedp.Evaluate(`$('.header').css("display" , "none");
		$('.btn-group').css("display" , "none");
		$('.container .container:first').css("display" , "none");
		$('.breadcrumb').css("display" , "none");
		$('.footer').css("display" , "none")`, &ins.buf),

打开网页后执行js代码把头尾隐藏掉

第三步:程序化,批量自动生成pdf

public static void createPDF(String folder , String cl ,  String pdfFile, String urlhref) {
        try {
            String fileName = pdfFile.replace("/", ":");
            String filePath = folder + fileName;
            File srcFile = new File(filePath);
            File newFolder = new File("/Volumes/disk2/myproject" + File.separator + cl);
            File destFile = new File(newFolder, fileName);
            if(destFile.exists()){
                return;
            }
            if(srcFile.exists()){
                //移动到对应目录
                if(!newFolder.exists()){
                    newFolder.mkdirs();
                }
                FileUtils.moveFile(srcFile , destFile);
                return;
            }
            if(!newFolder.exists()){
                newFolder.mkdirs();
            }
            String url = "http://127.0.0.1:8888/convert/html2pdf?u=doctron&p=lampnick&url="+urlhref;
            HttpEntity<String> entity = new HttpEntity<String>(null, null);
            RestTemplate restTemplate = new RestTemplate();
            ResponseEntity<byte[]> bytes = restTemplate.exchange(url, HttpMethod.GET, entity, byte[].class);
            if (bytes.getBody().length <= 100) {
                if(urlList.containsKey(urlhref)){
                    Integer failCount = urlList.get(urlhref);
                    if(failCount > 3){
                        System.out.println("下载失败:" + cl + " / " + pdfFile +"  " + urlhref);
                        return;
                    }
                    failCount++;
                    urlList.put(urlhref , failCount);
                }else{
                    urlList.put(urlhref , 1);
                }

                createPDF(folder , cl ,  pdfFile , urlhref);
            }else{
                if (!destFile.exists()) {
                    try {
                        destFile.createNewFile();
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
                try (FileOutputStream out = new FileOutputStream(destFile);) {
                    out.write(bytes.getBody(), 0, bytes.getBody().length);
                    out.flush();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

最终成果:


文件夹分类存放

pdf文件