用 python 写一个域名白名单爬虫

前段时间我写过一篇文章,说是时候使用白名单来翻墙了,不过那个白名单已经过期好久,用起来不是那么顺畅了,后来我就夸下海口说:我要自己实现一个爬虫,来爬取中国的网站域名,好更新白名单。

好吧,总之这个爬虫是写好了然后上线爬取了一万多的,不过最后我找到了前人做的更好的方案,于是这个爬虫项目还是废弃了。总之,白名单更强大了,只是没有使用这个爬虫而已。

爬虫是用 Python 写的,并没有使用经典的爬虫框架——因为我觉得我要写的爬虫太简单了没有必要去使用那么大的框架,于是自己实现了一个小轮子,一方面也是为了学习 Python 这门语言。好了,就说这么多,现在来记录一下整个的开发过程,供你参考?

设计

首先对于爬虫,总要能获取页面对吧,其实爬虫这个东西,就是下载网站页面然后获取内容,提取链接,然后根据链接继续下载页面。那么,我们的基本目标就出来了:

下载页面,获取页面里的所有网页链接,丢弃所有内容,继续获取链接。同时在这个过程中保存下获取到的链接。

由于我有一个国内的vps,那么逻辑就简单了很多——能访问到的域名一定是没有被墙的,访问不到的除了跪了的,那只能是被墙的了:)

(这个逻辑并不完善,事实上后边会遇到好多问题;接下来我会一点一点完善,这是一个思考的过程。其实再回忆总是有偏差的,但至少有个参考价值对吧?)

对于获取到的域名列表怎么管理呢?考虑到将来可能要刷新它们——毕竟可能将来某个域名可能会被墙掉对吧,所以我考虑还是使用数据库来管理这些域名,参数简单,一个数据库,一个表,里边一行一个域名即可。

对了,如果爬虫停止了,我们还希望它启动的时候不再重新开始,那么我们就应该能够保存它现在的执行状态,再考虑到获取链接容易,挨个执行去下载链接的页面困难(耗时),我们需要一个缓存机制。

或者说队列吧,这样更加确切。我建立了一个先入后出的队列,这是一个数组,当我获取到页面,就用正则表达式把里边的链接全部获取出来,放到数组尾端。

而蜘蛛爬取的时候,就从数组的头部获取,用一个删除一个,如果数组数量太大(比如说超过一万条),那么就先不再添加。

同时,我们爬取了一个页面,那说明这个域名是可用的,就加入白名单列表——当然,加入之前先看一下是否已经添加,如果添加过,就在流行度上 +1

你看,数据库对应的条目上,还可以顺便做一个简单的域名火热程度排名。这样考虑到将来白名单可能会有几万条,但其实一般使用并不会覆盖到这么多,我们可以选择输出前一万条之类的。

那么这样基本的逻辑就建立起来了,然后我们来设计一下所需要的类——毕竟,我们要面向对象。

没有什么是面向对象解决不了的问题,如果有,就再实例化一个类。

首先我们来一个 Spider 类,这个就是我们可爱的小蜘蛛,它要负责所有爬取的功能,比如获取下一个需要访问的页面,从种子开始爬取,从页面里读取链接等等。

其次是专门用来负责与数据库通信的 IO ,它负责封装一切与数据库的通信,这样就会很方便,我们在处理爬虫的时候就不用烦心数据库的问题了。

对了,最后要说一下:我用的是 Python 3

实现

大概的设计如此,现在我们来实现具体的类,先来说一说 IO :

IO

这个类负责数据库的通信,这里我使用的包是 pymysql ,另外为了再给数据库添加一个最后更新的时间标签,我们再使用一个 datetime  包来获取插入数据库时候的时间。

虽然我没有学过数据库原理,但这并不妨碍我部署并使用它。

这里我们使用 MySQL 作为数据库,创建一个名为 whitelist  的数据库,并创建一个名为 WhiteList  的表:

接下来我们就是实现具体的方法了:

这里我只给出部分代码实现,完整的在文章末尾我会给出 Github 仓库,你可以自己 clone 来阅读的。

上文是最更新域名条目的方法,首先获取游标,然后从游标获取数据,接着根据现有数据更新条目;如果没有对应的条目,那就直接插入新的条目。最后记得要 commit ,然后关闭游标。这里我用了几个名字代替了数字:

这样使用起来就方便了很多:)如你所见,我在名字前面都添加了两个下划线,作用是在 Python 里实现私有变量和方法。其实我觉得这一点挺坑的,这是我遇到的第一个坑。

程序员数数,怎么可以不从零开始?第零个坑: self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self,self 。

当然了,为了能让外部添加域名,我还是写了一个方法暴露在外边的:) (意义何在?)

Spider

现在,重头戏来了,我们的小蜘蛛。由于要使用到正则表达式,我用了 Python 包 re ,由于要使用 utf-8  编码解码页面,我还使用了 codecs ,当然了,要获取页面,所以还有 urllib3 。

先从一个最简单的方法开始,蜘蛛最开始总要先给它第一个种子的,不然它怎么开始?像我这种广度优先的需求,那自然从这种聚合网站域名的页面开始最好了。如有必要,其实还可以填写更多,不过这里其实我就写了一个。

这就是我们的主函数了,运行的话,只要循环调用这个“下一页”方法,爬虫就可以一直爬下去。按照我们的逻辑,他会先检测队列,如果队列为空,那么说明是刚启动,它就会去从种子启动了。

然后把访问成功的页面加入数据库,然后收集页面中的链接,其他资源就释放掉了。所以我们的这个小爬虫也就不需要在意什么 robot.txt ,因为它并不收集页面信息。

你看到的代码缩进有些奇怪,是因为我删除了部分无意义的内容,那些内容会在后边讲到。

从页面获取域名,如果队列已经大于一万,那就不再添加了,太多了。然后如果小于一万,我们就用正则表达式从页面里获取链接,,这个正则表达式是这样的: self.__domainRex = re.compile(r'http(s)?://([\w\-\_]+\.[\w\.\-\_]+)[\/\*]*') 这里会有一个问题,.cn域名是不需要判断的,它是国别域名,肯定能够访问的!

所以我们使用另外一个正则来额外剔除列表里的中国域名,正则表达式是这样的: self.__cnDomainRex = re.compile(r'\.cn(/)?$')

大部头来了!它就是我们要获取页面的方法,这里我们会遇到一些ssl页面,也就是Https,而 urllib3 默认并不支持,我们就需要配合另外一个包: certifi ,同时这里我们伪装了访问头,让它看起来更像是一个 Windows 用户在访问。

后来我遇到了一个问题,比如某些页面(jd.com)会出错,排查后发现京东用的是 gbk 编码,而我使用的一律是默认的 utf-8  ,所以我又使用了 chardet  来推断页面编码,虽然还是会有某些编码不能正确识别,但总体来说,那些错误已经小到可以忽略不计了。

前边说过,我们的蜘蛛首要目标是获取尽可能多的不同的域名,那么也就是所谓的广度优先了,所以使用了这两个方法来保证这一点:使用去重来去除列表里的重复链接,避免同一页面多次被访问(同一网站的多个不同页面还是要的)

最后,我们再把保存和读入缓存队列的方法写出来:

这两个方法前者会在类销毁的时候也就是停止蜘蛛的时候执行,后者则会在初始化也就是启动的时候执行,这样可以保证爬虫的爬行状态。

其实现在我们的爬虫就已经可以上线了——事实上这也是我的第一版测试版本。

收尾

这时候我们的项目还没有完成,因为它还是单线程阻塞的,主循环我还没有写,一旦执行,就是卡住完全不动了。所以,我们有必要让它后台执行。

首先我们先来完成主循环:

一次并发20个线程同时进行爬行。这里会有个问题,由于 Python 有个全局锁(具体内容请 Google 下吧,由于过去一段时间,当时的参考页面已经没有了),它“保证”了 Python 不可能实现真正的并发,所以,如果你在多核 CPU 上并发 Python,就会这样:

Python 的多线程
Python 的多线程

这就是我遇到的 Python 的第二个坑。

好吧,总之,我们还是需要让爬虫变成一个服务,我们现在需要在 main  里边进行操作了:

这是一个让 Python 后台的函数,它的原理是多进程,虽然子进程会在父进程退出后退出,但我们把它的目录切换到根,然后将它的标准输出重定向到日志文件里,这样这个进程就会保留下来:)

当然,只有这一个函数还是不够的:

我们在这里写入 pid 的路径,然后运行 main  函数:

为了方便操作,我给它加入了若干函数,这样我们就可以使用比如 ./main.py start 之类的命令来操作服务了!具体的函数我不再列举,你可以自行下载完整的代码去阅读。

这里我给出启动的函数,很简单对吧?先调用后台函数,然后正常实例化爬虫的类即可。这里我们先实例化IO,然后把IO实例引用传给Spider,然后调用它的 start() 方法。

这样,完整的程序就OK了,当然,接下来我就要说一说后面的修补问题了。

修补

俗话说,在已经发布的生产环境调试bug,就是这样的:

在已发布的产品中处理bug
在已发布的产品中处理bug

死循环

首先我遇到的问题就是重复域名被添加太多的情况,有时候会困在一个页面里出不来,比如从hao123开始,然后链接到某个页面,结果这个页面又有一个hao123的友链,那么爬虫就会爬回去重新再爬一遍hao123!,所以,这里我们用一个叫做“周知域名”的函数来限制重复的数量,我们说如果一个域名已经重复遇到过超过一万次(后来改为2000了),那这一定是个大站,就不要继续访问了。

线程安全

上文我们说了,Python 的并发就是个残废,可是它的线程安全问题倒是一点也不残废……也就是说,线程安全该做还得做。所以,我们要在每一个 IO 类方法调用的前后都加上线程安全语句,比如:

其他类似,这样保证了数据库访问不会冲突。

等待问题

我们说了,如果不能访问就是被墙的标准,可是显然我们对“不能访问”这个短语的定义是模糊的,对于爬虫来说,加载时间超时才是被墙的典型反映,可是每次都要这么等下去真的是要天荒地老了。所以,我们调用个 ping 来判断,如果 ping 都不通,那直接抛弃好了。这里要用到 subprocess  包了;同时,还需要 dnspython3  包来从域名查询 ip;以及 shlex 包:

这样就直接过滤掉了一大堆速度慢、超时的网站,大大加快了爬行速度。另外,后来我还想到,索性就对服务器 IP 的地址进行一下判断好了,如果不是中国区的 IP,直接返回 false 完事儿,毕竟国外的网站还是挂代理来的更快不是吗?

所以我又在网上找了一个判断的类加了进去,具体的就不贴了,毕竟是抄来的。

国别域名

由于我是使用了正则表达式来判断域名的,这里就会出现一些问题,比如想要判断国别域名实在是太困难了, .cc 这类还好说,那 .net.cc 这类我就崩溃了,而且。严格来讲.la这类的域名虽然是国别域名,但大家都在用也不能一网打尽……

最后我从 ICANN 官网找到了 CCTLD (即 Country Code Top Level Domain)列表,我稍微处理了一下直接用来匹配了,这样生成的列表里就不会有net.la这样的泛域名存在。

结尾

好了,这篇文章终于到了结尾……但我觉得写的并不是特别的详细,因为很多当时的具体问题我还是记不清了,很抱歉我一直拖到现在才写。?

毕竟这是我用 Python 写的第一个程序,所以代码实现看起来不那么优雅,不过还是那句话:

至少跑起来了。

行文我已经尽可能按照当时的开发过程来写,所以也只是展示了主要的核心代码,具体的代码仓库见 Github

另外,我说过,白名单已经不再依靠这个爬虫更新,转而使用了felixonmars 的dnsmasq-china-list ,不过这也直接导致白名单暴增到两万多条,已经不再适合手机端使用。

好吧,这篇文章就到这里。?

“用 python 写一个域名白名单爬虫”的2个回复

发表评论

电子邮件地址不会被公开。 必填项已用*标注

This site uses Akismet to reduce spam. Learn how your comment data is processed.