爬虫培训文档.1.1


1 / 24 爬虫培训文档 修订记录 版本 日期 提交人 版本描述 修改历史 2 / 24 1 项目简介 1.1 背景 公司开发人员入职前需做爬虫方面的培训,目前以该文档作为入职培训之一。 目前公司爬虫产品有:数据淘 http://datataotao.com/ 大家可以看看产品 1.2 目的 1.爬虫入门培训,熟悉基本采集数据的方法。 2.爬虫进阶,优化爬虫代码 2 技术方案 方案采用 python2.7+pyquery+requests 作为采集数据工具。 Python 是门比较通用的语言,目前应用于公司大部分业务。 pyquery 是类似 JS 里的 jquery 库,用于 html/xml 文件解析,可以很快捷地定位所需数 据,并且很方便获取格式化数据。 requests 是基于 urllib3 的网页请求库,它带来的不仅仅是代码上的精简,同时稍微处理 一下,可以大大提升网络请求的效率。使用了它,就像是在浏览器做操作一样方便简单。 2.1 爬虫入门 2.1.1 网络请求 2.1.1.1 初学储备 Python 里最简单,也是官方推荐的网络请求库 urllib2 中的写法如下: import urllib2 f = urllib2.urlopen('http://www.baidu.com/') print f.read(100) 可以看到,简单的网络请求,用 urllib2,并且写法还算精简。初学者建议用这个库,了 解一下稍底层网络请求。 参见:https://docs.python.org/2/library/urllib2.html 3 / 24 2.1.1.2 实战 实战中,使用 requests 可以提高编码效率,同时代码可读性高很多。官方有个和 urllib2 对比: 【urllib2】 import urllib2 gh_url = 'https://api.github.com' req = urllib2.Request(gh_url) password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() password_manager.add_password(None, gh_url, 'user', 'pass') auth_manager = urllib2.HTTPBasicAuthHandler(password_manager) opener = urllib2.build_opener(auth_manager) urllib2.install_opener(opener) handler = urllib2.urlopen(req) print handler.getcode() print handler.headers.getheader('content-type') 【requests】 import requests r = requests.get('https://api.github.com', auth=('user', 'pass')) print r.status_code print r.headers['content-type'] 同样功能的实现,用 requests 减少了一半以上的代码。 这其实是 requests 的其中一方面优势,它借用的 urllib3 更是它的优势之一,具体参考 爬虫进阶。 参见:http://docs.python-requests.org/en/latest/ 2.1.2 网页解析 网页解析,官方库里有 HTMLParser 和 htmllib,但这些库用起来还是很繁琐,我们当然 希望的是,我们能像人眼一样,迅速定位到我们所需要的数据。 4 / 24 pyquery 就是这么一个库,如果 html 页面够规整,基本上一次就能定位到我们所需的数 据。它可以定位 html 标签,也可以定位到 class。大部分情况下,定位 class 是最合适的,前 端工程师一般会让一个 class 代表该页面上一个样式。 比如下面的 html:
#再见 今天# A little consideration, a little thought for others, makes all the difference. 多给别人一 些体谅,多为别人考虑一点,那将让一切截然不同。
这里是其中一条微博的内容,假如现在要定位到微博内容: from pyquery import PyQuery as pq s = '以上 html 内容' p_html = pq(s) print p_html('.WB_detail')('div').eq(2).text() print p_html('.WB_text').text() 接下来,就可以尽情玩耍了。 参见:https://pythonhosted.org/pyquery/ 5 / 24 2.2 爬虫进阶 既然是进阶,那就必须要谈到爬虫效率,性能优化。也同时提及程序里一些优雅的写法。 2.2.1 性能优化篇 2.2.1.1 多线程 众人拾柴火焰高,首先性能的提升,就是多线程了。作为多线程,就涉及线程间通信控 制。线程间通信我们使用共享内存的方式,共享内存就存在互斥的问题,就要用到锁。锁是 个麻烦的东西,为了避免用锁,我们使用官方库 Queue(队列)。Queue 本身就是线程安全 的库,因此入队列和出队列均具有原子性。 并发编程的设计模式有很多,对于业务而言,使用生产者-消费者模式已经足够满足性 能要求了。下面程序是简单的,也是能满足基本爬虫需求的多线程程序。 # 生成 uid(生产者) class gen_uid_t(threading.Thread): def __init__(self, uid_queue): threading.Thread.__init__(self) self.uid_queue = uid_queue def run(self): global all_cnt f = open(uid_file, 'r') for line in f: all_cnt += 1 self.uid_queue.put(line.strip()) f.close() # 获取用户信息(消费者) class get_profile_t(threading.Thread): def __init__(self, uid_queue, info_queue): threading.Thread.__init__(self) self.uid_queue = uid_queue self.info_queue = info_queue def run(self): while 1: if uid_queue.qsize() == 0: break uid = self.uid_queue.get() while 1: try: 6 / 24 info = get_profile(uid) self.info_queue.put(info) break except: pass self.uid_queue.task_done() 这里需要注意的地方,就是最后一行:self.uid_queue.task_done()是为了通知父进程任务完成, 当所有任务都完成后,子进程退出,父进程也能知道所有任务完成了,处理相应操作。 2.2.1.2 打开文件过于频繁 以下面程序为例,先看代码: 【方案一】 mutex.acquire() f = open(result_fname, 'a+') f.write(r_json.encode('utf8') + '\n') f.close() mutex.release() 程序中,用了锁对文件进行读写操作控制,这是对的。如果没有用锁,文件的内容会出 现截断的情况。 但这里,每次获取锁以后,就执行打开文件,写数据到文件,关闭文件的操作。打开文 件就是一次 IO 操作,写数据到文件,如果不刷新缓存,那也是一次 IO 操作,关闭文件还有 一次。这样线程每执行一次就有三次 IO 操作了,还没算上网络请求的 IO。有没有更好的方 案呢? 【方案二】 mutex.acquire() f.write(r_json.encode('utf8') + '\n') mutex.release() 每次只做写数据操作,就减少了 2 次 IO,文件打开关闭就在线程的开始和结束时。相 对之前而言,这方案已经相对不错了。 Unix 哲学思想里有一句:让程序只做好一件事。应用于线程中,就叫“让线程只做好 一件事”。在我们目前的线程里,已经做了两件事:请求网络、写数据到文件(这里我们以 耗时间的当作事件,主要是 IO)。我们完全可以把这两部分剥离出来做,程序看起来更优美, 网络请求效率更高。 【方案三】 mutex.acquire() f_queue.put(r_json.encode('utf8') + '\n') mutex.release() 7 / 24 方案三中用了队列,把数据缓存在队列里,然后由另外一个线程专门做数据的写入操作。这 就实现了刚才所说的“让线程只做好一件事”。 2.2.1.3 套接字复用 Unix 哲学还有一句思想:一切皆文件。 到我们这就是,每一次网络请求,就要打开一个套接字文件,同时开启端口,然后才传输数 据。 【改进前代码】 import requests import time while 1: s = requests.Session() r = s.get('http://www.baidu.com') print r.text time.sleep(1) 我们来看看这段代码的抓包请求,这里用 tcpdump 命令:sudo tcpdump host www.baidu.com 8 / 24 框起来的部分,192.168.2.38.43349 变成了 192.168.2.38.40507,这里最后一个数据是端口号。 可以看到,每一次请求的端口都在变。由 TCP 三次握手协议可以知道,端口变了,那么每次 都要重新三次握手。这不是我们想要的,我们想要的,当然是一次连接,终生受用,哈哈。 【改进后代码】 import requests import time s = requests.Session() while 1: r = s.get('http://www.baidu.com') print r.text time.sleep(1) 再来看看抓包请求 清一色占用端口 43359,同时,不会在每一次请求中做一次 TCP 握手,这对网络请求的性能 而言,是极大的提升。 9 / 24 2.2.2 编程规范 2.2.2.1 锁的粒度 所谓粒度,即细化的程度。锁的粒度越大,则并发性越低且开销大;锁的粒度越小,则 并发性高且开销小。粒度大到一定程度,我们的多线程程序就变成单线程串行程序了。我们 不是在做数据库,要考虑行锁还是表锁,因此,当然是粒度越小越好。 来,看代码: 【改进前代码】 self.login_mutex.acquire() while 1: self.print_log('make new sesssion') if self.session_is_new==True: self.print_log('new sesssion already') break self.print_log('init new sesssion status') self.session=requests.Session() self.loginer=alimama_login(self.session) self.useful=None self.session_is_new=True self.session_new_time=time.time() self.vote_count=0 break self.login_mutex.release() 程序里的锁,控制是一整个循环体,可以这么认为,这里的临界区就是循环体,如果没有登 录成功,这个资源永远都不会被释放掉。临界区应该是 self.loginer=alimama_login(self.session) 这段。 【改进前代码】 while 1: self.print_log('make new sesssion') if self.session_is_new==True: self.print_log('new sesssion already') break self.print_log('init new sesssion status') self.session=requests.Session() self.login_mutex.acquire() self.loginer=alimama_login(self.session) self.login_mutex.release() self.useful=None 10 / 24 self.session_is_new=True self.session_new_time=time.time() self.vote_count=0 break 一般而言,锁的粒度原则就是越小越好。 2.2.2.2 异常处理 异常处理是个好东西,它可以丢弃不必要的异常,也可以处理特殊异常。但很多时候, 我们仅仅实现了第一点。这里就谈谈异常处理的粒度,不仅仅是用于异常,锁、循环体等都 是适用的。 【改进前代码】 try: self.session = self.session_ctrlor.copy_session() result = self.session.get(url, timeout = timeout_num).text if result == None or result == "" or result == "null": self.print_log("get info: url-error"+url) json_data = json.loads(result) pagelist = json_data.get("data").get("pagelist") if pagelist==None: print "yzm sleep-rand-to try again" self.session_ctrlor.vote_new(count=1) sleep_rand(3,9) continue message = json_data.get("info").get("message") if message == u"nologin": self.print_log("the cookie has been valied!") self.session_ctrlor.make_new() self.session_ctrlor.login() if self.session_ctrlor.is_useful(): continue else: sleep_rand(20,40) continue if message == u"您的操作频率达到上限,请稍后再试!": self.print_log("need sleep!!") sleep_rand(20,50) continue 11 / 24 break except requests.exceptions.RequestException, e: print str(self.getName()), "\t", e, "\t", "second_requests_error_fail_times:" fail = fail + 1 #print url continue except Exception, e: print str(self.getName()), "\t", e, "\t", "second_other_error_fail_times:", fail if str(e) == "No JSON object could be decoded": self.print_log("the cookie has been valied!") self.session_ctrlor.make_new() self.session_ctrlor.login() if self.session_ctrlor.is_useful(): continue else: sleep_rand(20,40) continue fail = fail + 1 if fail > 100: flag = 0 self.print_log("failure100times"+str(result)+str(url)) break print url print result continue 这段代码有没有看得很辛苦,看到后面都忘了捕捉的异常,到底是哪个了。异常的捕捉 和锁的控制一样,都需要精准,捕捉/控制特定可能有问题的代码。 很明显,代码中只是为了捕捉网络请求的异常,这很好找的,改进代码就懒得写了。 同时,代码量太长也是很大的问题,这么长的代码下来,其实就做一件事,但由于异常 的存在,导致没法对代码进行剥离。可以参考一个原则:一段代码在不用翻页就能完成。 2.2.2.3 嵌套太深 代码中最忌讳的一点,就是嵌套太深,代码可读性大大降低,并且难以维护。 下面这段代码: if table2_title_num != 0: list_title = [] for i in range(table2_title_num): # get all the title with a list one_title = doc("#resumeContentBody")(".resume-preview-all").eq(i)(".fc6699cc").text() 12 / 24 if one_title != None: list_title.append(one_title) #print json.dumps(list_title, ensure_ascii = False) for index, value in enumerate(list_title): if value == u"求职意向": table2_1 = doc("#resumeContentBody")(".resume-preview-all").eq(index)(".resume-preview-top").text() if table2_1 != None: list_table2_1 = table2_1.split(" ") second_title = {u"期望工作地区:": u"exp_location", u"期望 月薪:": u"exp_m_salary", u"目前状况:": u"work_status", u"期望工作性质:": u"work_attr", u"期望从事职业:": u"pro_title", u"期望从事行业:": u"exp_industry"} for index2_1, value2_1 in enumerate(list_table2_1): for i in second_title.keys(): if i == value2_1: if i == u"期望工作地区:": list_exp_location = list_table2_1[ index2_1 + 1 ].split(u"、") info_dict[ second_title[i] ] = list_exp_location break if i == u"期望从事职业:": list_pro_title = list_table2_1[ index2_1 + 1 ].split(u"、") info_dict[ second_title[i] ] = list_pro_title break info_dict[ second_title[i] ] = list_table2_1[ index2_1 + 1 ] break 看完后瞬间什么都不想看代码了,也不想去改进它。这段代码的逻辑要是再复杂一点,代码 都跑到右边去了。 说说改进原则吧: 1.避免太长的判断。 【改进前】 for i in range(1000): if a == True: print '1' print '2' print '3' 【改进后】 for i in range(1000): 13 / 24 if a == False: continue print '1' print '2' print '3' 2.把太长的代码,并且刚好实现一个功能点的,放进一个函数 14 / 24 3 总结 3.1 内容总结 写代码的时候,需要边写边思考,实现功能的同时,考虑程序怎么写会比较好看。 在性能优化时,注意每个细节,从每个细节着手考虑。 3.2 可改进点 15 / 24 个人在爬虫中的总结的一些体会 #-------------------# 爬虫,我觉得大概是 网络请求---->解析--->存储 下面写的目录是 1.网络请求 2.数据解析 3.数据存储 4.写程序的体会 网络请求: 无论是简单的网络请求还是复杂的网络请求.都可以模拟浏览器 的请求做到.基本上第一步就是使用浏览器看网络请求的包,然后用 同样的 python 代码去请求,这些照着做就可以了. 网络请求中比较复杂的是登录, 对登录的体会,我有以下两点 参数的加密 一些登录的参数是在浏览器内 js 加密生成的.简单的 js,可以观察 js 函 数加密的流程,用 python 去实现.复杂的,某网站自己的加密函数,建议利 用外部工具,不要强行纯 python, 比如就可以使用本地的 js 解释器调用 js 函数, 具体的举例: 企鹅的某个登录 我用的是 Spidermonkey(c 语言写的 js 解释器,编译后大约 2.5M 尚 可接受).将加密 js 文件函数整理为一个文件 enc,js(把 js 函数暴露出来, 同时可能还要去掉不必要的浏览器相关代码).在 python 中用 commands 包调用如下命令 ./js -e “load('./js/enc.js'); 16 / 24 print(Encryption.getEncryption('1dsds','\\xda\\x3d','saa');”(黑 色是简单的参数) 可以将复杂的加密过程转换为一条命令的调用.并得到加密结果, 实现登录.这种方法不优美,但是很快. 登录的控制 1.登录函数单独写出来并且命令测试 Python 代码最好分割干净,分成各个函数实现各个功能的代码. 登录函数也是,不要写到具体的主体流程中.最好写成例如: login_xxx(username,pssswd,session)这样的形式,登录一个 session 或者 session=login_xxx(username,pssswd) 成功后, 会 返回 一 个登 录的 session ,供后续使用. 这样就可以在程序提供命令测试 以供快速检测问题,是登录的错 误还是解析的错误 比如这样的代码 def loginA() def B(): Istrue,Session=loginA() if __name__==”__main__”: if sys.argv[1]==”test-login”: print loginA() 2 锁机制 在多线程的情况下,如果只有一个帐号,最好提供锁机制来控制登 录,因为,多线程的代码是一样的,登录失败时候,一个线程去请求登 录,其他线程很可能也是同时请求,造成在很短的时间内,一个帐号重 复尝试登录.轻则是不必要的的重复登录,重则有的网站就会出现频繁 登录.帐号异常,帐号锁定. 17 / 24 建议包装为一个登录类.内部实现锁机制.实例化的时候传给各个线 程.登录类里面 可以设置二次登录的时间间隔, 有当前的登录状态, 当前的登录 session ,和其他登录数据. 提供登录请求接口, ........ 比如这样的: class login_session_ctrl(object): def__init__(self,login_func,max_session_new_time=60): self.session=requests.Session() self.login_mutex = threading.Lock() self.login_func=login_func **** def __login(self): def is_useful(self): def copy_session(self): def make_new_session(self): 这样不同线程只需要从这个类里面调用 copy_session copy 一份登 录状态的 session, 而不用去使用登录函数,来登录自己的 session,这样 就只有一份 session ,再需要重新登录的时候调用 make_new_session 登录类根据提供的参数 max_session_new_time 和上次登录的时间,确定需不需要再次 登录.这个函数不会阻塞. 18 / 24 数据解析 python 的爬虫普遍是用 json 来存储数据,因为结构松散直观,缺 点是数据类型有限,时间类型就不统一..但是怎么说,最后都是字 符串罢了.目前来看还很好用 下面说几个方面. 字符的编码 字符编码大概是很蛋疼的一个小点了,不清楚就会很头疼.一会儿 这样,一会儿那样. 我的个人习惯:在 linux 下,输入输出文件中统一是 utf-8,内存中统 一是 unicode,除非是很简单的文件到文件,左手转右手的. 另外 Python 文件中的 中文一定要加 u”” 网络请求爬取的网页基本上是 utf-8 或者 gb2312 编码的,定向爬取 的话写死解码就好了, 注意1: 最好不要 pq(url) 这样的形式获取 pyquery 对象,pyquery 出来的 结果都是 unicode 形式的,这样有时会形成 u’\x78\x43\x23\x95’ 这样的乱码.这样形式上是 unicode 实际上内容仍然是 utf-8 或 gb2312.输出或写入文件就会出错.应该是请求数据解码成 unicode 再 交给 pq 转成 pyquery 对象来解析, 注意2: 使用 requests 的注意.resp=requests.get(url) 请求回来的结果 可以使用 resp.text 取回自动解码为 unicode 形式的数据 可以使用 resp.content 取回原始返回的数据 还可以使用 resp.json()将 json 格式的数据并自动化转 dict resp.text 一般不会出错,requests 可以根据网页声明自动转码. 但是如果没有声明,很可能就会出错,text 会返回 19 / 24 u’\x78\x43\x23\x95’ 这样的乱码,就需要自行由 resp.content (原始内容) 来转码,至于转码的格式一般测试几次就可以了. 这些自己一定要清楚了 如果出现乱码,还可以使用 chardet 包检测 content 原始数据 json json 格式, 我的观点和上面一样 到内存中使用 unicode 到文件中使用 utf-8 json.loads() 使用 utf-8 字符串到 unicode dict Json.dumps(,ensure_ascii=False).encode(‘utf-8’) unicode 字典到 utf-8 字符串 Pyquery 的解析 大部分直接照语法取就可以了, 注意 1: 有一个 xhtml_to_html 的函数,可以在 xhtml 出问题时试一 下,具体的很少碰到. 注意 2: 当网页结构比较混乱的时候,需要多想办法来解析,不必迷信 pyquery 正则 正则有时候特别有用,用的多了就熟悉了, 其他 爬虫部分的程序最好不要有过多的在线处理数据的程序,因为会影 20 / 24 响效率,另外写会更好. 数据存储 文件 文件操作 一般以读写为主. 读太大的文件,最好不要一次性读完, 写文件,要考虑性能和数据重要性考虑用不用 flush 数据库 基本参照各个数据库读取存储,修改数据. 还没有遇到太限制的. 接触的比较少,另外数目太多,最好不要用查询,遍历可能更好. 写程序的体会 程序整体 接触的爬虫大多是定向爬虫,很多都写到一个文件里,也无可厚非, 但是要么按照函数分割清楚.要么按照文件分割清楚. 测试 程序的各个重要函数,功能函数最好提供命令测试,方便查看调试 和手动使用 比如 登录的.一条记录的,一个用户,一次查询的 def A(): #do somethings def B(): C() for i in **: A() 21 / 24 def C(): pass if __name__==”__main__”: if sys.argv[1]==”testA”: print A(sys.argv[2]) if sys.argv[1]==”testC”: print C(sys.argv[2]) 这样里面的B函数只是架构方面的多次调用A,与爬虫的数据请求 网页解析都没有关系,出错的时候,仅仅修改A就可以了.这时候不用 启动一大堆东西,直接命令测试 testA 就可以了 中断重启 爬虫程序最好写成健壮的,可以中断后简单处理就可以重新启动 的.不用每次都从头再来. 这个要求可能会让程序有些复杂.建议在运行函数外面再套一个处 理启动的程序.例如: 一个读入 id 查询出结果写入 output 文件的一个爬虫,可以定义一 个这样类型的读入函数: def read(input_file,input_queue,filter_dict=filter_dict) 在读入 id 的时候,将 filter_dict 里面有的 id 预先 pass 掉,而这个 filter_dict 是由上次的 output 文件读进来的,只用在启动的时候读一 次.过滤第一次之后跟据情况再决定要不要清空 filter_dict,节省内存. 当然了,如果 output 已经很大的话,filter_dict 就会占很大的内 存,需要在程序外,手动用另外的程序来处理.下面再讲. 22 / 24 多线程的架构 多线程大概是爬虫提高效率的关键了.多线程里 python 常使用 Queue 作为任务队列.一些线程的用法,百度就可以了. 但是多线程应该不是写爬虫程序的重点,应该是作为可扩展的一部 分每次套上去就可以了. 最常见的是生产者消费者 一个输入队列关键词 另一个读关键 词,调用函数,得到结果,放到结果队列,剩下一个从结果队列读,写 到文件.爬虫实现上面三个线程类.依次启动三种线程,如下图. 最终要等这些线程都退出后退出. 这种写法,每次都要重写类,将功能函数写到类里面.虽然改动的 代码少,但是终究不像是重用的样子.我的做法是用自己写的一个通用 的线程类,实例化的时候,将自己的功能函数和需要的参数作为实例化 参数放进去,每次就只用配合模式写出功能函数. 这个类代替上面的中间的那一个.主要是实现自己的 func 就可以了. 23 / 24 另外一些的程序流程一般依照自己的需求来设计.只要流程清楚,程序 就不会混乱. 程序小点 处理数据的程序也是爬虫的一部分, 这些程序可以实现过滤,去重,合并,提取,差集,等一般数据操 作,在爬虫中断的时候,可以从输出中提取已经爬取过的数据,再提取 输入的差集,重新启动. 另外一些处理数据的程序可以合并不同部分的数据,或者解析爬取 的粗糙数据,这些都应该是必备的. 写的这些程序最好有扩展性,应对一定范围内的不同情况. 比如这种取输出的输入中 def get_id_from_A(line_data): #return line_data.strip() #return line_data.strip().decode('utf-8') return json.loads(line_data).get('cv_id') #return line_data.split('\t',1)[0] def get_id_from_B(line_data): #return line_data.strip().decode('utf-8') return json.loads(line_data).get('cv_id') #return line_data.strip() #return line_data.strip().split('\t')[0] #return json.loads(line_data).get('key') def main(): ..... 24 / 24 不同的情况只是得到 id 的方式不同,程序主体还是一样的,所 以可以处理很多情况. 除了这些,awk grep sed 正则和管道都应该会一些,了解他们的运 行参数和一般用法,对处理数据很有帮助. (请参考<>) 多线程平滑 多线程最怕同步,在有 sleep 时候,随机一些,避免竞争...或许 是我想多了.. 结尾词: 爬虫目前写的就这么多,以后再补充, 目前的不足: 1.爬虫还没有做过安全登录的, 2.或者也可以开发浏览器的插件来做爬虫.这样很多浏览器的东西就可以 用.
还剩23页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 10 金币 [ 分享pdf获得金币 ] 1 人已下载

下载pdf

pdf贡献者

vinnking

贡献于2018-01-25

下载需要 10 金币 [金币充值 ]
亲,您也可以通过 分享原创pdf 来获得金币奖励!
下载pdf