关于代码重构,我的经验是不用一开始就尽善尽美,那样不仅耽误进度,而且有可能开始就弄得太复杂,要关注的细节太多。在不停的完善程序功能的过程中,一些明显需要优化的地方,可以顺便改起来,也不要等到全部功能完成之后再重构,那样也会感觉一团乱麻不好下手,很容易改着改着就引入bug了。
重构无处不在,哪怕是写一些很小的程序,一样有提升的空间。最近写了个简单的爬虫,原本的目的是想给自己省时间。因为经常去某体育论坛的二手器材板块浏览帖子,通常是按时间倒序排列,看看最近的帖子标题里面有没有自己感兴趣的兵器。由于每天的新主题都可以排满好几页(每页50主题),想要快速浏览过去通常会看漏,仔细看又觉得浪费时间,况且很多主题里面根本不写清楚到底出售什么商品,还得点进去看主贴才知道。另一方面,由于发帖需要消耗论坛金币,很多人都会重新编辑帖子重复利用,单纯按时间倒序来浏览会错过很多信息,但是又不可能把有效期(比如一个月)内的帖子都翻一遍。鉴于这种情况,于是萌发了写一个爬虫自动收集信息的想法,语言自然是Python,虽然在工作中用了好几年Python了, 但是爬虫还真是第一次写。
稍微查了一些资料得知目前已经有很流行的爬虫框架,不仅可以爬取整个网站,还能设置多个代理IP地址支持多个任务同时运行。由于我的小程序只是自己用的,只需要搜集特定的信息,并且对效率也没有要求,反正就挂在那里运行,等自己的事干完再去看结果就可以了,所以就用自己的真实IP地址,把请求网页的频率放慢点也是可以的。要完成这个工作并不难,只需要两个库,一个是requests用来和web服务器交互,另一个是beautifulsoup负责解析html格式的文档,为了提高网页解析速度还额外安装一个lxml库作为beautifulsoup的默认的解析库。程序首先获取列表页,因为是按时间倒序排列的,url的参数会有个orderby=dateline,至于需要第N页就加上page=N即可,目前国内流行的discuz论坛差不多都是这种参数。然后解析列表页把里面所有主题的url拿到,并继续请求这些页面。其实帖子标题和作者以及发表时间在列表页通常也都有了,但是我觉得还是去具体的帖子页面里面获取这些信息比较好,比如过长的标题在列表页可能会被压缩,而发帖时间也不太精确,所以在列表页只需要帖子的唯一标识ID和URL就够了。待拿到具体的帖子,再把每一层楼的内容都拿到,一楼则包含主贴信息,因为不管是按热度还是回复时间或者其他什么因素排序,一楼的位置总归不变。同时找到下一页的链接,并继续请求页面。
实现这个功能并不难,对我而言比较烦人的事情反而是分析HTML页面找到合适的标签作为查找的依据。等实现需要的功能之后,代码已经是一团mess,于是觉得有必要重构一下了。首先是把长串的程序拆成短小函数,解析查找HTML标签的代码占据太多空间,以后想要修改都会麻烦。一旦网页的结构发生变化,程序抛出异常,结构化的程序也会更方便查看。于是整理成若干个函数,比如get_row_elements_in_list_page就是把一张列表页里面的行元素取出来,然后针对每个元素调用get_topic_id和get_topic_url便得到帖子的唯一ID和链接,对于具体的帖子,可以用get_topic_title获取帖子的标题,get_row_elements_in_discussion_page获得包含贴子详情的行元素,对此调用get_post_time,get_replier_with_profile_url,get_post_content_with_images可以把每条帖子的发布时间、作者、作者个人主页链接、文本、图片等信息抠出来,还有get_next_page_url获取下一页的链接,等等。这么清理一下之后,感觉舒服多了。
程序结构整理好以后,猛然发现其实所有的论坛都具有类似的一个结构,哪怕不是discuz生成的web应用,其他网络讨论版其实也是大同小异。这么看来程序稍作修改也可以应用在其他论坛。于是创建一个Crawler抽象类使其 __metaclass__= abc.ABCMeta,上面一段提到的各个get_*函数全部变成abstractmethod,需要由派生类去实现。当然每个论坛的Host信息有所不同(向Web服务器发送request时需要添加进header),列表页的查询URL格式也不同,甚至服务端允许客户端请求网页的时间间隔也不同,那么这些方法都可以抽象出来,比如叫做get_host_address,get_query_url_format,time_delay_between_requests。不变的部分是处理流程,从请求列表页开始,得到一大堆URL,再请求各个主贴页面,解析,如果还有下一页则继续解析。另外基类可以提供一些默认函数,由派生类决定要不要实现,比如网页的encoding,通常requests库可以自动处理,但不排除有特殊的情况,所以可以实现一个get_encoding的非抽象方法返回空值,如果子类重写了它返回具体的编码信息比如'utf-8'、'gbk',那么可以把此参数传给请求返回的response对象保证其正常工作。完成这部分以后,下次要去爬取一个新的论坛,就只需要关注一下网页的格式然后填充一下各个抽象函数就好了。
接下来仍旧有优化的空间,目前这个程序的处理过程是完全同步的。虽然一开始就说了只是个人用的小程序对性能没有太高要求,也没有用到代理IP,但是CPU计算和IO等待交替进行还是太落伍了。于是创造了3个Python的Queue队列,起3个线程同时工作,一个队列负责存放网页的URL,并将提取到的页面塞入另一个队列,这个队列中的页面被取出并分析,如果有next page会继续投递URL到前面的队列,而获得的文本则提交到下一个队列进行正则匹配。几个队列之间的消息有专门定义的结构,可以设定某些标志告诉任务已经完成可以退出。为了避免多线程的同步问题,设置了一个字典结构专门存储帖子信息,另一个集合结构存储被命中的帖子ID,分别被两个不同的线程访问。不过这么做似乎还不完美,主线程需要通过join等待子线程,一旦子线程抛出异常,程序并不会终止,因为没有发送结束标志,线程就会卡在那里。这时需要设置Queue的timeout属性,或者按照网上的一些办法让子线程退出。其实在Python里面最好的办法还是用协程,即使不用gevent,python3里面也提供了一些新式的异步交互方式,不过暂时理解还不太深入,下一个程序或许就会按照时髦一点的方式来写。
以上三步是比较重要的重构,另外还有一些完善,比如用argparse接收命令行参数,可以给程序添加很多选项,再比如将最后的结果也输出为一个html页面,添加一些js脚本,可以动态的展开标题以显示正文并加载图片。在测试的过程中还碰到很多异常,有些帖子需要很高的权限才能看,有些作者被关小黑屋了,都会导致网页结构发生变化,因此在除错的过程中也会向爬虫类添加新的函数并给与默认的实现,子类可以根据需要重新实现它。写这个程序很多精力都花在细节的处理上了,类似于提取帖子中的图片,替换文本里的URL长串以免对正则匹配关键字产生影响。最后还写了一个和main函数同级的简单的test函数,设定提取几个典型的页面,把正常和特殊的情况都覆盖进去,以后一旦有什么问题,也省得从头到尾跑程序再等待异常。
总之,用动态语言编程,最大的优点就是速度快,构造原型是很方便的。但是为了写出优雅且健壮的程序,不断的重构和完善测试代码也是必不可少的。