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

通过本文,我们将开始
迭代地创建您的履带式
自行车 ,分析许多功能并遇到陷阱。 从简单的递归功能到可扩展和可扩展的服务。 一定很有趣!
前言
反复进行-这意味着在每个发行版本的末尾,都应具有可商定的限制,功能和界面的“产品”的即用型版本。
选择
Node.js和
JavaScript作为平台和语言,因为它简单且异步。 当然,对于工业发展而言,技术基础的选择应基于业务需求,期望和资源。 作为演示和原型,此平台完全无效(IMHO)。
这是我的履带。 有很多这样的履带,但这是我的。
我的履带是我最好的朋友。
实施搜寻器是一项相当受欢迎的任务,即使在技术采访中也可以找到。 确实有很多现成的(
Apache Nutch )和自写的解决方案可以针对不同的条件和多种语言。 因此,任何来自个人开发或使用经验的评论都将受到欢迎,并且将很有趣。
问题陈述
tyap-blooper搜寻器的第一个(初始)实现的任务如下:
一拖二爬行者1.0
编写一个爬网程序脚本,绕过一个小型(最多100页)网站的内部<a href />链接。 结果,提供具有所接收代码的页面URL列表以及它们的链接图。 robots.txt规则和rel = nofollow链接属性将被忽略。
注意! 由于明显的原因,忽略
robots.txt规则是一个坏主意。 我们将在将来弥补这一遗漏。 同时,添加限制参数以限制要爬网的页面数,以免停止DoS并尝试实验站点(最好使用自己的“仓鼠站点”进行实验)。
实作
对于不耐烦的人,
这是此解决方案
的来源 。
- HTTP(S)客户端
- 答案选项
- 链接提取
- 链接准备和过滤
- URL规范化
- 主要功能算法
- 返回结果
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) => {
现在我们可以异步接收响应,但是目前我们还没有做任何事情。
2.答案选项
要抓取该站点,只需处理3个答案选项即可:
- OK-收到2xx状态代码。 有必要保存响应主体作为进一步处理的结果-提取新链接。
- 重定向 -收到3xx状态代码。 这是重定向到另一个页面。 在这种情况下,我们将需要位置响应标头,从那里我们将获得一个“传出”链接。
- NO_DATA-所有其他情况:4xx / 5xx和3xx,而没有Location标头。 我们的爬虫无处可走。
提取功能将解析处理后的响应,指示其类型:
const ft = { 'OK': 1,
按照
if-else的最佳传统实施产生结果的策略:
let code = res.statusCode; let codeGroup = Math.floor(code / 100);
准备使用
提取功能:
完整的功能代码 。
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);
该函数搜索子字符串(
baseHost ),并检查是否找到了该子字符串:因为
wwwexample.com和
example.com是不同的域。 因此,我们不会离开给定的域,而是绕过其子域。
我们通过添加“绝对化”并过滤生成的链接来优化
提取功能:
function extract(fetched, src, base) { return extractRaw(fetched) .map(href => url.resolve(src, href)) .filter(dst => /^https?\:\/\
此处
获取是
获取功能的结果,
src是源页面的URL,
base是爬网的基本URL。 在输出中,我们获得了已经是绝对内部链接(URL)的列表,以进行进一步处理。 整个功能代码可以在
这里看到 。
5. URL规范化
重新遇到任何URL,您不需要发送对资源的另一个请求,因为已经接收到数据(或者另一个连接仍处于打开状态并等待响应)。 但是,比较两个URL的字符串来理解这一点并不总是足够的。 规范化是确定语法上不同的URL的等效性所必需的过程。
规范化过程是应用于源URL及其组件的整套转换。 这里只是其中一些:
- 方案和主机不区分大小写,因此应将其转换为较低的值。
- 所有百分比(例如“%3A”)必须为大写。
- 可以删除默认端口(HTTP为80)。
- 该片段( # )对服务器是永远不可见的,也可以删除。
您可以随时使用现成的东西(例如
normalize-url ),也可以编写自己的简单函数来涵盖最重要和最常见的情况:
function normalize(dst) { let dstUrl = new URL(dst);
是的,没有排序查询参数,忽略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) {
通过一个简单的请求计数器检查是否达到页数限制。 第二个计数器(一次活动请求的数量)将用作测试是否准备好给出结果的(当值变为零时)。 如果
获取功能无法获取下一页,则将其状态代码设置为null。
您可以(可选)
在此处熟悉实现代码,但是在
此之前,您应该考虑返回结果的格式。
7.返回结果
我们将为轮询的页面引入一个唯一的
ID标识符,并以简单的增量递增:
let id = 0; let cache = {};
为了获得结果,让我们创建一个
页面数组,在其中我们将在页面上添加包含数据的对象:
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规则,
抓取延迟指令等。 从实现的角度来看,这首先是对消息进行排队,然后为更大的负载提供服务。
如果这种材料当然很有趣!