当前位置: 首页 > 科技观察

基于Node.js的声明式可监控爬虫网络

时间:2023-03-14 17:15:20 科技观察

爬虫是数据抓取的重要手段之一,以Scrapy、Crawler4j、Nutch为代表的开源框架可以帮助我们快速搭建分布式爬虫系统;在我看来,我们在开发大型爬虫系统时可能会面临以下挑战:Web爬虫:最简单的爬虫就是使用HTTP客户端,比如HTTPClient或者fetch或者request。现在随着单页应用等富客户端应用的流行,我们可以使用Selenium、PhantomJS等无头浏览器来动态执行脚本进行渲染。网页分析:网页内容的提取和分析是一个非常麻烦的问题。DOM4j、Cherrio和beautifulsoup为我们提供了基本的分析功能。作者也尝试过搭建一个全配置的爬虫,类似于Web-Scraper,但还是输给了复杂多变、多层嵌套的iFrame页面。在这里,笔者秉承代码即配置的理念。配置声明的内置复杂度比较低,但是对于那些业务复杂度高的网页,整体复杂度会呈几何级数增长。使用代码声明其内置的复杂度和门槛比较高,但是可以更好的处理业务复杂度高的网页。笔者在构思未来交互式爬虫生成接口时,也希望借鉴FaaS的思想,直接用代码来声明整个解析过程,而不是使用配置。反爬虫对抗:像淘宝这样的主流网站基本上都有反爬虫机制。他们会从请求频率、请求地址、请求行为、目标连贯性等多个维度进行分析,从而判断请求者是爬虫还是真人。用户。我们常用的方式是使用多个IP或者多个代理来避免同源的频繁请求,也可以借鉴GAN或者增强学习的思路,让爬虫根据目标的反爬虫策略自动升级改造自己网站。另一种常见的反爬虫方法是验证码。从最初的迷惑图片,到常见的拖拽验证码,都有不小的障碍。我们可以尝试通过从图片中提取文本并模拟用户行为来绕过它们。分布式调度:单机的吞吐量和性能始终存在瓶颈,分布式爬虫和其他分布式系统一样,需要考虑分布式治理、数据一致性、任务调度等问题。笔者个人的感觉是,爬虫的工作节点尽量是无状态的,将整个爬虫集群的状态存储在Redis或Consul等能够保证高可用的中心存储中。在线有价值页面预测:Google经典的PageRank可以根据网络中的连接信息判断一个URL的价值,从而优先索引或抓取有价值的页面。而像Anthelion这样的智能解析工具,可以根据之前提取的页面内容的价值,预测某个URL是否需要爬取。页面内容提取和存储:提取网页中结构化或非结构化的内容实体是自然语言处理中的常见任务之一,而从海量数据中自动提取有意义的内容还涉及到机器学习、大数据处理等知识领域。我们可以使用HadoopMapReduce、Spark、Flink等离线或流式计算引擎处理海量数据,使用词嵌入、主题模型、LSTM等机器学习技术对文本进行分析,使用HBase和ElasticSearch对文本进行存储或索引。笔者无意重新发明轮子,而是在改造我们公司一个简单的命令式爬虫的过程中,发现很多调度和监控操作应该由框架来完成。在开发大型分布式应用程序时,Node.js可能不像Java或Go那样一致(JavaScript未标准化)和性能。但是正如作者上面提到的,JavaScript的优势在于它可以通过同构代码同时运行在客户端和服务端,所以以后解析步骤完全可以在客户端调试,然后直接在客户端运行代码服务器。这对于构建灵活多变的分析可能是有意义的。总而言之,我只是想有一个可扩展、可监控、易用的爬虫框架,所以我很快推出了一个declarative-crawler,目前只是在原型阶段,还没有发布到npm;特别是,我发现有同类型的框架我可以吱吱叫。让我看看我是否可以使用它们来了解更多信息。设计思路和架构概述作者在几年前写第一个爬虫的时候,整体思路是典型的命令式编程,即先抓取再解析,最后持久化存储,如下代码:awaitfetchListAndContentThenIndex('jsgc',section.name,section.menuCode,section.category).then(()=>{}).catch(error=>{console.log(error);});命令式编程与声明式编程相比耦合度更高,可测试性和可控性更低;就像从jQuery切换到React、Angular、Vue.js等框架一样,我们应该把业务之外的事情尽可能的交给工具和框架来管理和解决,这样也有利于我们自定义监控。总结起来,作者的设计思路主要有以下几点:关注点分离。整个架构分为爬虫调度CrawlerScheduler、Crawler、Spider、dcEmitter、Store、KoaServer、MonitorUI等部分,尽可能的分离职责。在声明式编程中,每个蜘蛛的生命周期包括爬取、提取、解析、持久化存储;开发者应该独立声明这些部分,完整的调用和调度应该由框架来完成。层是可独立测试的。以爬虫的生命周期为例,提取和解析应该声明为纯函数,而抓取和持久化存储更面向业务,可以mock或带有副作用的测试。整个爬虫网络架构如下图,目前所有的代码都可以在这里找到。自定义蜘蛛和爬虫我们以爬取在线列表和详情页面为例。首先,我们需要为两个页面构建蜘蛛。注意,每个蜘蛛负责抓取和解析某个URL。用户应该先写list一个爬虫需要声明model属性,重写before_extract、parse和persist方法,每个方法都会被串行调用。另外需要注意的是,我们的爬虫可能会在外部传入一些配置信息,统一声明在extra属性中,这样在持久化的时候也可以使用。typeExtraType={module?:string,name?:string,menuCode?:string,category?:string};exportdefaultclassUAListSpiderextendsSpider{displayName="通用公告列表蜘蛛";extra:ExtraType={};model={$announcements:'tr[height="25"]'};constructor(extra:ExtraType){super();this.extra=extra;}before_extract(pageHTML:string){returnpageHTML.replace(//gim,"");}parse(pageElements:Object){letannouncements=[];letannouncementsLength=pageElements.$announcements.length;for(leti=0;i{letflag=true;//这里的每个URL对应一个announcement数组for(letannouncementofannouncements){try{awaitinsertOrUpdateAnnouncement({...this.extra,...announcement,infoID:href2infoID(announcement.href)});}catch(err){flag=false;}}returnflag;}}我们可以定位这个spider是单独测试的,这里用的是Jest。注意,为了描述方便,没有提取、解析等单元测试。在大型项目中,我们建议添加这些纯功能测试用例。varexpect=require("chai").expect;importUAListSpiderfrom"../../src/universal_announcements/UAListSpider.js";letuaListSpider:UAListSpider=newUAListSpider({module:"jsgc",name:"房屋建筑市政招标公告-服务类",menuCode:"001001/001001001/00100100100",category:"1"}).setRequest("http://ggzy.njzwfw.gov.cn/njggzy/jsgc/001001/001001001/001001001001/?Paging=1",{});test("抓取公共列表",async()=>{letannouncements=awaituaListSpider.run(false);expect(announcements,"返回数据为列表,长度大于10").to.have.length.above(2);});test("抓取公共列表并执行持久化操作",async()=>{letannouncements=waituaListSpider.run(true);expect(announcements,"返回数据是一个列表,长度大于10").to.have.length.above(2);});同样,我们可以为详情页定义爬虫:",//内容$content:"#tblInfo#TDContent"};parse(pageElements:Object){...}asyncpersist(announcement:Object){...}}定义好spider之后,我们就可以定义Crawler负责爬取整个系列的任务了。注意Spider只负责Crawl单个页面,分页等操作由Crawler进行:/***@functiongeneralcrawler*/exportdefaultclassUACrawlerextendsCrawler{displayName="universalannouncementcrawler";/***@constructor*@paramconfig*@paramextra*/constructor(extra:ExtraType){super();extra&&(this.extra=extra);}initialize(){//构建所有爬虫letrequests=[];for(leti=startPage;i{if(!Array.isArray(announcements)){thrownewError("爬虫连接失败!");}returnannouncements.map(announcement=>({url:`http://ggzy.njzwfw.gov.cn/${announcement.href}`}));}).setSpider(newUAContentSpider(this.extra));}}最重要的爬虫最重要的是initialize函数,在这个函数中需要完成爬虫的初始化。首先我们需要构建所有的种子链接,这里是多个列表页;然后通过setSpider方法添加对应的spider。不同的爬虫使用自定义的Transformer函数从之前的结果中提取需要的链接,传递给下一个爬虫。到目前为止,已经定义了我们的爬虫网络的关键组件。本地运行定义好Crawler后,我们就可以通过将爬虫注册到CrawlerScheduler来运行爬虫了:",menuCode:"001001/001001001/00100100100",category:"1"});crawlerScheduler.register(uaCrawler);dcEmitter.on("StoreChange",()=>{console.log("----------"+newDate()+"--------");console.log(store.crawlerStatisticsMap);});crawlerScheduler.run().then(()=>{});这里的dcEmitter是整个状态的中转站。如果选择使用本地操作,可以自己监控dcEmitter中的事件:------------WedApr19201722:12:54GMT+0800(CST)-----------{UACrawler:CrawlerStatistics{isRunning:true,spiderStatisticsList:{UAListSpider:[Object],UAContentSpider:[Object]},instance:UACrawler{name:'UACrawler',displayName:'GeneralannouncementCrawler',spiders:[Object],转换:[Object],requests:[Object],isRunning:true,extra:[Object]},lastStartTime:2017-04-19T14:12:51.373Z}}服务器运行我们你也可以将爬虫作为服务运行:constcrawlerScheduler:CrawlerScheduler=newCrawlerScheduler();letuaCrawler=newUACrawler({module:"jsgc",name:"房建市政招标公告-服务",menuCode:"001001/001001001/00100100100",category:"1"});crawlerScheduler.注册(uaCrawler);newCrawlerServer(crawlerScheduler).run().then(()=>{},(error)=>{console.log(error)});此时会启动框架内置的Koa服务器,让用户通过RESTful接口控制爬虫网络,获取当前状态接口说明KeyFieldCrawler//判断爬虫是否运行isRunning:boolean=false;//上次启动爬虫的时间lastStartTime:Date;//lastFinishTime:上次运行爬虫的日期***;//Crawler***异常信息lastError:Error;spider//***一次运行时间lastActiveTime:Date;//平均总执行时间/msexecuteDuration:number=0;//爬虫统计count:number=0;//异常次数统计errorCount:number=0;countByTime:{[number]:number}={};localhost:3001/获取当前爬虫运行状态未启动[{name:"UACrawler",displayName:"通用公告爬虫",isRunning:false,}]正常返回[{name:"UACrawler",displayName:"通用公告爬虫",isRunning:true,lastStartTime:"2017-04-19T06:41:55.407Z"}]错误[{name:"UACrawler",displayName:"通用公告Spider",isRunning:true,lastStartTime:"2017-04-19T06:46:05.410Z",lastError:{spiderName:"UAListSpider",message:"抓取超时",url:"http://ggzy.njzwfw.gov.cn/njggzy/jsgc/001001/001001001/001001001001?Paging=1",time:"2017-04-19T06:47:05.414Z"}}]localhost:3001/start启动爬虫{message:"OK"}本地主机:3001/status返回当前系统状态{"cpu":0,"memory":0.9945211410522461}localhost:3001/UACrawler根据爬虫名称查看爬虫运行状态[{"name":"UAListSpider","displayName":"Universal公告列表蜘蛛","count":6,"countByTime":{"0":0,"1":0,"2":0,"3":0,..."58":0,"59":0},"lastActiveTime":"2017-04-19T06:50:06.935Z","executeDuration":1207.4375,"errorCount":0},{"name":"UAContentSpider","displayName":"通用公告内容蜘蛛","count":120,"countByTime":{"0":0,..."59":0},"lastActiveTime":"2017-04-19T06:51:11.072Z","executeDuration":1000.1596102359835,"errorCount":0}]自定义监控接口CrawlerServer提供了RESTfulAPI返回当前爬虫的状态信息,我们可以使用React或者其他框架快速搭建监控接口【本文是专栏作者“张子雄”原创文章,如需转载请联系作者】点此查看作者更多好文