前段时间我写过一篇文章,说是时候使用白名单来翻墙了,不过那个白名单已经过期好久,用起来不是那么顺畅了,后来我就夸下海口说:我要自己实现一个爬虫,来爬取中国的网站域名,好更新白名单。
好吧,总之这个爬虫是写好了然后上线爬取了一万多的,不过最后我找到了前人做的更好的方案,于是这个爬虫项目还是废弃了。总之,白名单更强大了,只是没有使用这个爬虫而已。
爬虫是用 Python 写的,并没有使用经典的爬虫框架——因为我觉得我要写的爬虫太简单了没有必要去使用那么大的框架,于是自己实现了一个小轮子,一方面也是为了学习 Python 这门语言。好了,就说这么多,现在来记录一下整个的开发过程,供你参考?
设计
首先对于爬虫,总要能获取页面对吧,其实爬虫这个东西,就是下载网站页面然后获取内容,提取链接,然后根据链接继续下载页面。那么,我们的基本目标就出来了:
下载页面,获取页面里的所有网页链接,丢弃所有内容,继续获取链接。同时在这个过程中保存下获取到的链接。
由于我有一个国内的vps,那么逻辑就简单了很多——能访问到的域名一定是没有被墙的,访问不到的除了跪了的,那只能是被墙的了:)
(这个逻辑并不完善,事实上后边会遇到好多问题;接下来我会一点一点完善,这是一个思考的过程。其实再回忆总是有偏差的,但至少有个参考价值对吧?)
对于获取到的域名列表怎么管理呢?考虑到将来可能要刷新它们——毕竟可能将来某个域名可能会被墙掉对吧,所以我考虑还是使用数据库来管理这些域名,参数简单,一个数据库,一个表,里边一行一个域名即可。
对了,如果爬虫停止了,我们还希望它启动的时候不再重新开始,那么我们就应该能够保存它现在的执行状态,再考虑到获取链接容易,挨个执行去下载链接的页面困难(耗时),我们需要一个缓存机制。
或者说队列吧,这样更加确切。我建立了一个先入后出的队列,这是一个数组,当我获取到页面,就用正则表达式把里边的链接全部获取出来,放到数组尾端。
而蜘蛛爬取的时候,就从数组的头部获取,用一个删除一个,如果数组数量太大(比如说超过一万条),那么就先不再添加。
同时,我们爬取了一个页面,那说明这个域名是可用的,就加入白名单列表——当然,加入之前先看一下是否已经添加,如果添加过,就在流行度上 +1
你看,数据库对应的条目上,还可以顺便做一个简单的域名火热程度排名。这样考虑到将来白名单可能会有几万条,但其实一般使用并不会覆盖到这么多,我们可以选择输出前一万条之类的。
那么这样基本的逻辑就建立起来了,然后我们来设计一下所需要的类——毕竟,我们要面向对象。
没有什么是面向对象解决不了的问题,如果有,就再实例化一个类。
首先我们来一个 Spider 类,这个就是我们可爱的小蜘蛛,它要负责所有爬取的功能,比如获取下一个需要访问的页面,从种子开始爬取,从页面里读取链接等等。
其次是专门用来负责与数据库通信的 IO ,它负责封装一切与数据库的通信,这样就会很方便,我们在处理爬虫的时候就不用烦心数据库的问题了。
对了,最后要说一下:我用的是 Python 3
实现
大概的设计如此,现在我们来实现具体的类,先来说一说 IO :
IO
这个类负责数据库的通信,这里我使用的包是 pymysql ,另外为了再给数据库添加一个最后更新的时间标签,我们再使用一个 datetime 包来获取插入数据库时候的时间。
虽然我没有学过数据库原理,但这并不妨碍我部署并使用它。
这里我们使用 MySQL 作为数据库,创建一个名为 whitelist 的数据库,并创建一个名为 WhiteList 的表:
1 2 3 4 5 6 7 |
CREATE TABLE WhiteList ( DomainRank int NOT NULL, Domain varchar(255) NOT NULL, LastUpdate date NOT NULL, PRIMARY KEY (Domain) ) |
接下来我们就是实现具体的方法了:
1 2 3 4 5 6 7 8 9 10 11 12 |
def __updateDomain(self,domain): cur = self.conn.cursor() cur.execute('select * from WhiteList where Domain=%s',domain) data = cur.fetchall() if data: rank = data[0][self.__DomainRank] cur.execute('update WhiteList set DomainRank=%s,LastUpdate=%s where domain=%s',(rank + 1,datetime.datetime.now().strftime("%Y%m%d"),domain)) self.conn.commit() else: cur.execute('insert into WhiteList (Domain,DomainRank,LastUpdate) values (%s,%s,%s)',(domain,1,datetime.datetime.now().strftime("%Y%m%d"))) self.conn.commit() cur.close() |
这里我只给出部分代码实现,完整的在文章末尾我会给出 Github 仓库,你可以自己 clone 来阅读的。
上文是最更新域名条目的方法,首先获取游标,然后从游标获取数据,接着根据现有数据更新条目;如果没有对应的条目,那就直接插入新的条目。最后记得要 commit ,然后关闭游标。这里我用了几个名字代替了数字:
1 2 3 |
__DomainRank = 0 __Domain = 1 __LastUpdate = 2 |
这样使用起来就方便了很多:)如你所见,我在名字前面都添加了两个下划线,作用是在 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 。
1 2 |
def saveDomain(self,domain): self.__updateDomain(domain) |
当然了,为了能让外部添加域名,我还是写了一个方法暴露在外边的:) (意义何在?)
Spider
现在,重头戏来了,我们的小蜘蛛。由于要使用到正则表达式,我用了 Python 包 re ,由于要使用 utf-8 编码解码页面,我还使用了 codecs ,当然了,要获取页面,所以还有 urllib3 。
1 2 |
def __getSeeds(self): return ['www.hao123.com'] |
先从一个最简单的方法开始,蜘蛛最开始总要先给它第一个种子的,不然它怎么开始?像我这种广度优先的需求,那自然从这种聚合网站域名的页面开始最好了。如有必要,其实还可以填写更多,不过这里其实我就写了一个。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def __nextPage(self): if len(self.__domainList) == 0: self.__domainList = self.__getSeeds() url = self.__domainList.pop(0) pageContent = self.__getPage(url) topDomain = self.__topDomainRex.findall(url) self.__io.saveDomain(topDomain[0]) self.__gatherDomainFromPage(pageContent) |
这就是我们的主函数了,运行的话,只要循环调用这个“下一页”方法,爬虫就可以一直爬下去。按照我们的逻辑,他会先检测队列,如果队列为空,那么说明是刚启动,它就会去从种子启动了。
然后把访问成功的页面加入数据库,然后收集页面中的链接,其他资源就释放掉了。所以我们的这个小爬虫也就不需要在意什么 robot.txt ,因为它并不收集页面信息。
你看到的代码缩进有些奇怪,是因为我删除了部分无意义的内容,那些内容会在后边讲到。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
def __gatherDomainFromPage(self,page): if len(self.__domainList) > 10000: return try: m = self.__domainRex.findall(page) except: #print('Get wrong data! skip it!') return domainList = [] if m: for domain in m: domainList.append(domain[1]) domainList = self.__deDuplicate(domainList) domainList = self.__checkDomainFromList(domainList) self.__domainList += domainList |
从页面获取域名,如果队列已经大于一万,那就不再添加了,太多了。然后如果小于一万,我们就用正则表达式从页面里获取链接,,这个正则表达式是这样的: self.__domainRex = re.compile(r'http(s)?://([\w\-\_]+\.[\w\.\-\_]+)[\/\*]*') 这里会有一个问题,.cn域名是不需要判断的,它是国别域名,肯定能够访问的!
1 2 3 4 5 6 7 8 9 10 11 |
def __checkDomainFromList(self,list): domainList = [] for domain in list: m = self.__cnDomainRex.findall(domain) if m: continue else: domainList.append(domain) return domainList |
所以我们使用另外一个正则来额外剔除列表里的中国域名,正则表达式是这样的: self.__cnDomainRex = re.compile(r'\.cn(/)?$')
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
def __getPage(self,url): http = urllib3.PoolManager( cert_reqs='CERT_REQUIRED', # Force certificate check. ca_certs=certifi.where(), # Path to the Certifi bundle. ) data = '' try: data = http.request('GET', url, timeout=10, headers={ 'User-agent' : 'Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.9.1.5) Gecko/20091102 Firefox/3.5.5'} ).data codeType = chardet.detect(data) data = data.decode(codeType['encoding']) except: pass return data |
大部头来了!它就是我们要获取页面的方法,这里我们会遇到一些ssl页面,也就是Https,而 urllib3 默认并不支持,我们就需要配合另外一个包: certifi ,同时这里我们伪装了访问头,让它看起来更像是一个 Windows 用户在访问。
后来我遇到了一个问题,比如某些页面(jd.com)会出错,排查后发现京东用的是 gbk 编码,而我使用的一律是默认的 utf-8 ,所以我又使用了 chardet 来推断页面编码,虽然还是会有某些编码不能正确识别,但总体来说,那些错误已经小到可以忽略不计了。
1 2 3 4 5 6 7 8 |
def __deDuplicate(self,list): result = [] for item in list: try: result.index(item) except: result.append(item) return result |
前边说过,我们的蜘蛛首要目标是获取尽可能多的不同的域名,那么也就是所谓的广度优先了,所以使用了这两个方法来保证这一点:使用去重来去除列表里的重复链接,避免同一页面多次被访问(同一网站的多个不同页面还是要的)
最后,我们再把保存和读入缓存队列的方法写出来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
def __cache(self): f = codecs.open('./domainlistCache','w','utf-8') for domian in self.__domainList: f.write(domian + '\n') f.close() def __getLastTimeList(self): try: f = codecs.open('./domainlistCache', 'r','utf-8') for line in f.readlines(): line = line.strip('\n') self.__domainList.append(line) f.close() except: pass |
这两个方法前者会在类销毁的时候也就是停止蜘蛛的时候执行,后者则会在初始化也就是启动的时候执行,这样可以保证爬虫的爬行状态。
其实现在我们的爬虫就已经可以上线了——事实上这也是我的第一版测试版本。
收尾
这时候我们的项目还没有完成,因为它还是单线程阻塞的,主循环我还没有写,一旦执行,就是卡住完全不动了。所以,我们有必要让它后台执行。
首先我们先来完成主循环:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
def start(self): self.__domainList = self.__getSeeds() # self.__cache() while True: threads = [] self.__cache() for thread in range(20): threads.append(threading.Thread(target=self.__nextPage)) for thread in threads: thread.start() for thread in threads: thread.join() |
一次并发20个线程同时进行爬行。这里会有个问题,由于 Python 有个全局锁(具体内容请 Google 下吧,由于过去一段时间,当时的参考页面已经没有了),它“保证”了 Python 不可能实现真正的并发,所以,如果你在多核 CPU 上并发 Python,就会这样:
这就是我遇到的 Python 的第二个坑。
好吧,总之,我们还是需要让爬虫变成一个服务,我们现在需要在 main 里边进行操作了:
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 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
def daemonize(pidfile, *, stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'): if os.path.exists(pidfile): raise RuntimeError('Already running') # First fork (detaches from parent) try: if os.fork() > 0: raise SystemExit(0) # Parent exit except OSError as e: raise RuntimeError('fork #1 failed.') # os.chdir('/') os.umask(0) os.setsid() # Second fork (relinquish session leadership) try: if os.fork() > 0: raise SystemExit(0) except OSError as e: raise RuntimeError('fork #2 failed.') # Flush I/O buffers sys.stdout.flush() sys.stderr.flush() # Replace file descriptors for stdin, stdout, and stderr with open(stdin, 'rb', 0) as f: os.dup2(f.fileno(), sys.stdin.fileno()) with open(stdout, 'ab', 0) as f: os.dup2(f.fileno(), sys.stdout.fileno()) with open(stderr, 'ab', 0) as f: os.dup2(f.fileno(), sys.stderr.fileno()) # Write the PID file with open(pidfile, 'w') as f: print(os.getpid(), file=f) # Arrange to have the PID file removed on exit/signal atexit.register(lambda: os.remove(pidfile)) # Signal handler for termination (required) def sigterm_handler(signo, frame): raise SystemExit(1) signal.signal(signal.SIGTERM, sigterm_handler) |
这是一个让 Python 后台的函数,它的原理是多进程,虽然子进程会在父进程退出后退出,但我们把它的目录切换到根,然后将它的标准输出重定向到日志文件里,这样这个进程就会保留下来:)
当然,只有这一个函数还是不够的:
1 2 3 |
if __name__=='__main__': PIDFILE = '/tmp/GFW-White-Domain-List-daemon.pid' main() |
我们在这里写入 pid 的路径,然后运行 main 函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
def main(): if len(sys.argv) == 1: help() return if sys.argv[1] == 'start': startSpider() elif sys.argv[1] == 'status': status() elif sys.argv[1] == 'stop': stop() elif sys.argv[1] == 'restart': stop() startSpider() elif sys.argv[1] == 'list': outPutList() elif sys.argv[1] == 'help': help() else: print('Unknown command {!r}'.format(sys.argv[1]), file=sys.stderr) |
为了方便操作,我给它加入了若干函数,这样我们就可以使用比如 ./main.py start 之类的命令来操作服务了!具体的函数我不再列举,你可以自行下载完整的代码去阅读。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def startSpider(): print('WhiteList spider started!', file=sys.stderr) try: daemonize(PIDFILE, stdout='/tmp/spider-log.log', stderr='/tmp/spider-err.log') except RuntimeError as e: print(e, file=sys.stderr) raise SystemExit(1) io = IO.IO() spider = Spider.Spider(io) spider.start() |
这里我给出启动的函数,很简单对吧?先调用后台函数,然后正常实例化爬虫的类即可。这里我们先实例化IO,然后把IO实例引用传给Spider,然后调用它的 start() 方法。
这样,完整的程序就OK了,当然,接下来我就要说一说后面的修补问题了。
修补
俗话说,在已经发布的生产环境调试bug,就是这样的:
死循环
首先我遇到的问题就是重复域名被添加太多的情况,有时候会困在一个页面里出不来,比如从hao123开始,然后链接到某个页面,结果这个页面又有一个hao123的友链,那么爬虫就会爬回去重新再爬一遍hao123!,所以,这里我们用一个叫做“周知域名”的函数来限制重复的数量,我们说如果一个域名已经重复遇到过超过一万次(后来改为2000了),那这一定是个大站,就不要继续访问了。
1 2 3 4 5 6 |
def __isWellKnown(self,domain): topDomain = self.__topDomainRex.findall(domain) self.__lock.acquire() result = self.__io.getDomainRank(topDomain) self.__lock.release() return result > 2000 |
线程安全
上文我们说了,Python 的并发就是个残废,可是它的线程安全问题倒是一点也不残废……也就是说,线程安全该做还得做。所以,我们要在每一个 IO 类方法调用的前后都加上线程安全语句,比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
def __nextPage(self): self.__lock.acquire() if len(self.__domainList) == 0: self.__domainList = self.__getSeeds() # print(self.__domainList) url = self.__domainList.pop(0) self.__lock.release() if self.__pingDomain(url): if self.__isWellKnown(url) : return #skip this domain if wellknowen pageContent = self.__getPage(url) topDomain = self.__topDomainRex.findall(url) self.__lock.acquire() self.__io.saveDomain(topDomain[0]) self.__lock.release() self.__gatherDomainFromPage(pageContent) |
其他类似,这样保证了数据库访问不会冲突。
等待问题
我们说了,如果不能访问就是被墙的标准,可是显然我们对“不能访问”这个短语的定义是模糊的,对于爬虫来说,加载时间超时才是被墙的典型反映,可是每次都要这么等下去真的是要天荒地老了。所以,我们调用个 ping 来判断,如果 ping 都不通,那直接抛弃好了。这里要用到 subprocess 包了;同时,还需要 dnspython3 包来从域名查询 ip;以及 shlex 包:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
def __pingDomain(self,domain): a = [] isAvailable = True try: a = self.__resolver.query(domain) except: isAvailable = False if len(a) == 0:return False ip = str(a[0]) if self.__chinaIP.isChinaIP(ip): cmd = "ping -c 1 " + ip args = shlex.split(cmd) try: subprocess.check_call(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) except subprocess.CalledProcessError: isAvailable = False else: isAvailable = False return isAvailable |
这样就直接过滤掉了一大堆速度慢、超时的网站,大大加快了爬行速度。另外,后来我还想到,索性就对服务器 IP 的地址进行一下判断好了,如果不是中国区的 IP,直接返回 false 完事儿,毕竟国外的网站还是挂代理来的更快不是吗?
所以我又在网上找了一个判断的类加了进去,具体的就不贴了,毕竟是抄来的。
国别域名
由于我是使用了正则表达式来判断域名的,这里就会出现一些问题,比如想要判断国别域名实在是太困难了, .cc 这类还好说,那 .net.cc 这类我就崩溃了,而且。严格来讲.la这类的域名虽然是国别域名,但大家都在用也不能一网打尽……
最后我从 ICANN 官网找到了 CCTLD (即 Country Code Top Level Domain)列表,我稍微处理了一下直接用来匹配了,这样生成的列表里就不会有net.la这样的泛域名存在。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
def outPutList(): # count = 10000 cctlds = TLDS.getCCTLDS() tlds = TLDS.getTLDS() io = IO.IO() list = io.getList() f = codecs.open('./whitelist.txt','w','utf-8') print('Output top 10000 domains in whitelist.txt\n', ) i = 0 skip = 0 #its ugly ... but worked! #fk country code top-level domain!!!! for item in list: d = re.findall(r'.\w{2}$',item[1]) if d: if cctlds.__contains__(d[0]): t = re.findall(r'^\w+',item[1]) if tlds.__contains__(t[0]): skip += 1 continue i += 1 f.write(item[1]+'\n') if i == 10000: break print('done! got '+str(i)+' domains.\n and skip '+str(skip)+' error domain.\n') |
结尾
好了,这篇文章终于到了结尾……但我觉得写的并不是特别的详细,因为很多当时的具体问题我还是记不清了,很抱歉我一直拖到现在才写。?
毕竟这是我用 Python 写的第一个程序,所以代码实现看起来不那么优雅,不过还是那句话:
至少跑起来了。
行文我已经尽可能按照当时的开发过程来写,所以也只是展示了主要的核心代码,具体的代码仓库见 Github。
另外,我说过,白名单已经不再依靠这个爬虫更新,转而使用了felixonmars 的dnsmasq-china-list ,不过这也直接导致白名单暴增到两万多条,已经不再适合手机端使用。
好吧,这篇文章就到这里。?
本文由 落格博客 原创撰写:落格博客 » 用 python 写一个域名白名单爬虫
转载请保留出处和原文链接:https://www.logcg.com/archives/1697.html
代码好乱,不过学习了。。
?都是直接贴的,所以真的很乱……总之,github上有完整代码,然后我也懒得贴更多了……………………嘿嘿。