我们为一个或两个1.0编写一个爬虫

网络搜寻 (或网络蜘蛛)是搜索引擎的重要组成部分,用于搜寻网页,以便将有关它们的信息输入数据库,主要是为了进一步索引。 搜索引擎(Google,Yandex,Bing)以及SEO产品(SEMrush,MOZ,ahrefs)不仅具有这种功能。 这件事很有趣:无论是在潜力和用例方面,还是在技术实施方面。



通过本文,我们将开始迭代地创建您的履带式自行车 ,分析许多功能并遇到陷阱。 从简单的递归功能到可扩展和可扩展的服务。 一定很有趣!

前言


反复进行-这意味着在每个发行版本的末尾,都应具有可商定的限制,功能和界面的“产品”的即用型版本。

选择Node.jsJavaScript作为平台和语言,因为它简单且异步。 当然,对于工业发展而言,技术基础的选择应基于业务需求,期望和资源。 作为演示和原型,此平台完全无效(IMHO)。

这是我的履带。 有很多这样的履带,但这是我的。
我的履带是我最好的朋友。

实施搜寻器是一项相当受欢迎的任务,即使在技术采访中也可以找到。 确实有很多现成的( Apache Nutch )和自写的解决方案可以针对不同的条件和多种语言。 因此,任何来自个人开发或使用经验的评论都将受到欢迎,并且将很有趣。

问题陈述


tyap-blooper搜寻器的第一个(初始)实现的任务如下:

一拖二爬行者1.0
编写一个爬网程序脚本,绕过一个小型(最多100页)网站的内部<a href />链接。 结果,提供具有所接收代码的页面URL列表以及它们的链接图。 robots.txt规则和rel = nofollow链接属性将被忽略。

注意! 由于明显的原因,忽略robots.txt规则是一个坏主意。 我们将在将来弥补这一遗漏。 同时,添加限制参数以限制要爬网的页面数,以免停止DoS并尝试实验站点(最好使用自己的“仓鼠站点”进行实验)。

实作


对于不耐烦的人, 这是此解决方案的来源

  1. HTTP(S)客户端
  2. 答案选项
  3. 链接提取
  4. 链接准备和过滤
  5. URL规范化
  6. 主要功能算法
  7. 返回结果

1. HTTP(S)客户端


实际上,我们需要做的第一件事是通过HTTP和HTTPS发送请求和接收响应。 在node.js中,有两个匹配的客户端。 当然,您可以接受现成的客户端请求 ,但是对于我们的任务来说,这是非常多余的:我们只需要发送GET请求并获取带有正文和标头的响应即可。

我们需要的两个客户端的API都相同,我们将创建一个地图:

const clients = { 'http:': require('http'), 'https:': require('https') }; 

我们声明一个简单的函数fetch ,其唯一参数将是所需Web资源字符串的绝对 URL。 使用url模块,我们会将结果字符串解析为URL对象。 该对象具有一个带有协议的字段(带有冒号),我们将通过该字段选择适当的客户端:

 const url = require('url'); function fetch(dst) { let dstURL = new URL(dst); let client = clients[dstURL.protocol]; if (!client) { throw new Error('Could not select a client for ' + dstURL.protocol); } // ... } 

接下来,使用选定的客户端并将fetch函数的结果包装在promise中:

 function fetch(dst) { return new Promise((resolve, reject) => { // ... let req = client.get(dstURL.href, res => { // do something with the response }); req.on('error', err => reject('Failed on the request: ' + err.message)); req.end(); }); } 


现在我们可以异步接收响应,但是目前我们还没有做任何事情。

2.答案选项


要抓取该站点,只需处理3个答案选项即可:

  1. OK-收到2xx状态代码。 有必要保存响应主体作为进一步处理的结果-提取新链接。
  2. 重定向 -收到3xx状态代码。 这是重定向到另一个页面。 在这种情况下,我们将需要位置响应标头,从那里我们将获得一个“传出”链接。
  3. NO_DATA-所有其他情况:4xx / 5xx和3xx,而没有Location标头。 我们的爬虫无处可走。

提取功能将解析处理后的响应,指示其类型:

 const ft = { 'OK': 1, // code (2xx), content 'REDIRECT': 2, // code (3xx), location 'NO_DATA': 3 // code }; 

按照if-else的最佳传统实施产生结果的策略:

 let code = res.statusCode; let codeGroup = Math.floor(code / 100); // OK if (codeGroup === 2) { let body = []; res.setEncoding('utf8'); res.on('data', chunk => body.push(chunk)); res.on('end', () => resolve({ code, content: body.join(''), type: ft.OK })); } // REDIRECT else if (codeGroup === 3 && res.headers.location) { resolve({ code, location: res.headers.location, type: ft.REDIRECT }); } // NO_DATA (others) else { resolve({ code, type: ft.NO_DATA }); } 

准备使用提取功能: 完整的功能代码

3.提取链接


现在,根据收到的答案的变体,您需要能够从访存的结果数据中提取链接以进行进一步的爬网。 为此,我们定义了extract函数,该函数将结果对象作为输入并返回新链接的数组。

如果结果类型为REDIRECT,则该函数将返回一个包含位置字段中单个引用的数组。 如果为NO_DATA,则为空数组。 如果确定,那么我们需要连接解析器以显示所显示的文本内容以进行搜索。

对于搜索任务<a href />,您还可以编写正则表达式。 但是此解决方案根本无法扩展,因为将来我们至少会关注链接的其他属性( rel ),最大程度地,我们将考虑img链接脚本音频/视频 )和其他资源。 解析文档的文本并构建其节点的树以绕过常规选择器,这是非常有前途且更加方便的。

我们将使用流行的JSDOM库在node.js中使用DOM:

 const { JSDOM } = require('jsdom'); let document = new JSDOM(fetched.content).window.document; let elements = document.getElementsByTagName('A'); return Array.from(elements) .map(el => el.getAttribute('href')) .filter(href => typeof href === 'string') .map(href => href.trim()) .filter(Boolean); 

我们从文档中获取所有A元素,然后获取href属性的所有过滤值(如果不是空行)。

4.链接的准备和过滤


提取程序的结果是,我们有一组链接(URL)和两个问题:1)URL可能是相对的; 2)URL可能导致外部资源(我们现在只需要内部资源)。

第一个问题将通过url.resolve函数得到帮助,该函数可相对于源页面的URL解析登录页面的URL。

为了解决第二个问题,我们在inScope中编写了一个简单的实用程序函数,该函数将登录页面的主机与当前爬网的基本URL的主机进行检查:

 function getLowerHost(dst) { return (new URL(dst)).hostname.toLowerCase(); } function inScope(dst, base) { let dstHost = getLowerHost(dst); let baseHost = getLowerHost(base); let i = dstHost.indexOf(baseHost); // the same domain or has subdomains return i === 0 || dstHost[i - 1] === '.'; } 

该函数搜索子字符串( baseHost ),并检查是否找到了该子字符串:因为wwwexample.comexample.com是不同的域。 因此,我们不会离开给定的域,而是绕过其子域。

我们通过添加“绝对化”并过滤生成的链接来优化提取功能:

 function extract(fetched, src, base) { return extractRaw(fetched) .map(href => url.resolve(src, href)) .filter(dst => /^https?\:\/\//i.test(dst)) .filter(dst => inScope(dst, base)); } 

此处获取获取功能的结果, src是源页面的URL, base是爬网的基本URL。 在输出中,我们获得了已经是绝对内部链接(URL)的列表,以进行进一步处理。 整个功能代码可以在这里看到

5. URL规范化


重新遇到任何URL,您不需要发送对资源的另一个请求,因为已经接收到数据(或者另一个连接仍处于打开状态并等待响应)。 但是,比较两个URL的字符串来理解这一点并不总是足够的。 规范化是确定语法上不同的URL的等效性所必需的过程。

规范化过程是应用于源URL及其组件的整套转换。 这里只是其中一些:

  • 方案和主机不区分大小写,因此应将其转换为较低的值。
  • 所有百分比(例如“%3A”)必须为大写。
  • 可以删除默认端口(HTTP为80)。
  • 该片段( )对服务器是永远不可见的,也可以删除。

您可以随时使用现成的东西(例如normalize-url ),也可以编写自己的简单函数来涵盖最重要和最常见的情况:

 function normalize(dst) { let dstUrl = new URL(dst); // ignore userinfo (auth property) let origin = dstUrl.protocol + '//' + dstUrl.hostname; // ignore http(s) standart ports if (dstUrl.port && (!/^https?\:/i.test(dstUrl.protocol) || ![80, 8080, 443].includes(+dstUrl.port))) { origin += ':' + dstUrl.port; } // ignore fragment (hash property) let path = dstUrl.pathname + dstUrl.search; // convert origin to lower case return origin.toLowerCase() // and capitalize letters in escape sequences + path.replace(/%([0-9a-f]{2})/ig, (_, es) => '%' + es.toUpperCase()); } 

以防万一,URL对象的格式


是的,没有排序查询参数,忽略utm标签,处理_escaped_fragment_以及其他(绝对)不需要的东西。

接下来,我们将创建Crawl框架请求的标准化URL的本地缓存。 在发送下一个请求之前,我们将接收到的URL规范化,如果它不在缓存中,则添加它,然后才发送新请求。

6.主要功能的算法


解决方案的关键组件(原始)已经准备就绪,是时候开始将所有内容收集在一起了。 首先,让我们确定爬网函数的签名:在输入处,开始URL和页面限制。 该函数返回一个promise,其分辨率提供累积的结果; 将其写入输出文件:

 crawl(start, limit).then(result => { fs.writeFile(output, JSON.stringify(result), 'utf8', err => { if (err) throw err; }); }); 

爬网功能最简单的递归工作流可以在以下步骤中进行描述:

1.初始化缓存和结果对象
2.如果登录页面网址(通过normalize )不在缓存中,则
-2.1。 如果达到限制 ,则END(等待结果)
-2.2。 将URL添加到缓存
-2.3。 在结果中保存源页面和目标页面之间的链接
-2.4。 每页发送异步请求( 提取
-2.5。 如果请求成功,则
--2.5.1。 从结果中提取新链接( extract
--2.5.2。 对于每个新链接,执行算法2-3
-2.6。 ELSE将该页面标记为错误
-2.7。 保存页面数据到结果
-2.8。 如果这是最后一页,则带来结果
3. ELSE将源和登录页面之间的链接保存在结果中

是的,该算法将来会发生重大变化。 现在,在额头上特意使用了递归解决方案,以便以后最好“感觉”实现上的差异。 实现该功能的工件如下所示:

 function crawl(start, limit = 100) { // initialize cache & result return new Promise((resolve, reject) => { function curl(src, dst) { // check dst in the cache & pages limit // save the link (src -> dst) to the result fetch(dst).then(fetched => { extract(fetched, dst, start).forEach(ln => curl(dst, ln)); }).finally(() => { // save the page's data to the result // check completion and resolve the result }); } curl(null, start); }); } 

通过一个简单的请求计数器检查是否达到页数限制。 第二个计数器(一次活动请求的数量)将用作测试是否准备好给出结果的(当值变为零时)。 如果获取功能无法获取下一页,则将其状态代码设置为null。

您可以(可选) 在此处熟悉实现代码,但是在之前,您应该考虑返回结果的格式。

7.返回结果


我们将为轮询的页面引入一个唯一的ID标识符,并以简单的增量递增:

 let id = 0; let cache = {}; // ... let dstNorm = normalize(dst); if (dstNorm in cache === false) { cache[dstNorm] = ++id; // ... } 

为了获得结果,让我们创建一个页面数组,在其中我们将在页面上添加包含数据的对象: id {number}, url {string}和代码 {number | null}(现在足够了)。 我们还为对象之间的页面之间的链接创建了一个链接数组: (源页面的ID (登陆页面的ID )。

出于参考目的,在解决结果之前,我们以id的升序对页面列表进行排序(毕竟,答案将以任何顺序出现),在结果中添加扫描的计数页面和达到指定的fin限制的标志:

 resolve({ pages: pages.sort((p1, p2) => p1.id - p2.id), links: links.sort((l1, l2) => l1.from - l2.from || l1.to - l2.to), count, fin: count < limit }); 

使用范例


完成的搜寻器脚本具有以下概要:

 node crawl-cli.js --start="<URL>" [--output="<filename>"] [--limit=<int>] 

补充过程关键点的日志记录,我们将在启动时看到这样的画面:

 $ node crawl-cli.js --start="https://google.com" --limit=20 [2019-02-26T19:32:10.087Z] Start crawl "https://google.com" with limit 20 [2019-02-26T19:32:10.089Z] Request (#1) "https://google.com/" [2019-02-26T19:32:10.721Z] Fetched (#1) "https://google.com/" with code 301 [2019-02-26T19:32:10.727Z] Request (#2) "https://www.google.com/" [2019-02-26T19:32:11.583Z] Fetched (#2) "https://www.google.com/" with code 200 [2019-02-26T19:32:11.720Z] Request (#3) "https://play.google.com/?hl=ru&tab=w8" [2019-02-26T19:32:11.721Z] Request (#4) "https://mail.google.com/mail/?tab=wm" [2019-02-26T19:32:11.721Z] Request (#5) "https://drive.google.com/?tab=wo" ... [2019-02-26T19:32:12.929Z] Fetched (#11) "https://www.google.com/advanced_search?hl=ru&authuser=0" with code 200 [2019-02-26T19:32:13.382Z] Fetched (#19) "https://translate.google.com/" with code 200 [2019-02-26T19:32:13.782Z] Fetched (#14) "https://plus.google.com/108954345031389568444" with code 200 [2019-02-26T19:32:14.087Z] Finish crawl "https://google.com" on count 20 [2019-02-26T19:32:14.087Z] Save the result in "result.json" 

这是JSON格式的结果:

 { "pages": [ { "id": 1, "url": "https://google.com/", "code": 301 }, { "id": 2, "url": "https://www.google.com/", "code": 200 }, { "id": 3, "url": "https://play.google.com/?hl=ru&tab=w8", "code": 302 }, { "id": 4, "url": "https://mail.google.com/mail/?tab=wm", "code": 302 }, { "id": 5, "url": "https://drive.google.com/?tab=wo", "code": 302 }, // ... { "id": 19, "url": "https://translate.google.com/", "code": 200 }, { "id": 20, "url": "https://calendar.google.com/calendar?tab=wc", "code": 302 } ], "links": [ { "from": 1, "to": 2 }, { "from": 2, "to": 3 }, { "from": 2, "to": 4 }, { "from": 2, "to": 5 }, // ... { "from": 12, "to": 19 }, { "from": 19, "to": 8 } ], "count": 20, "fin": false } 


已经可以做什么呢? 至少,页面列表可以找到站点的所有损坏页面。 有了有关内部链接的信息,您可以检测重定向的长链(和闭环)或按参考质量查找最重要的页面。

公告2.0


我们获得了最简单的控制台搜寻器的一种变体,它绕过了一个站点的页面。 源代码在这里 。 还有一些功能的示例和单元测试

现在,这是一个不礼貌的请求发送者,下一步的合理步骤是教他良好的举止。 这将涉及用户代理标头, robots.txt规则, 抓取延迟指令等。 从实现的角度来看,这首先是对消息进行排队,然后为更大的负载提供服务。 如果这种材料当然很有趣!

Source: https://habr.com/ru/post/zh-CN441024/


All Articles