如何构建构建一个AI驱动的通用爬虫

我最近开始研究网络爬虫,并且考虑到AI领域的一切发展,我认为尝试构建一个可以迭代导航网络直到找到它所寻找的内容的“通用”爬虫会很有趣。这是一个正在进行中的工作,但我想分享一下我目前的进展。

规格

给定一个起始URL和一个高级目标,网络爬虫应该能够:

  1. 分析给定的网页
  2. 从任何相关部分提取文本信息
  3. 执行任何必要的交互
  4. 重复执行,直到达到目标

工具

尽管这严格来说是一个后端项目,我还是决定使用NextJs来构建这个,以防我以后想要添加前端。对于我的网络爬虫库,我决定使用Crawlee,它提供了一个围绕Playwright的包装器,Playwright是一个浏览器自动化库。Crawlee增加了浏览器自动化的增强功能,使得将爬虫伪装成人类用户变得更容易。它们还提供了一个方便的请求队列,用于管理请求的顺序,这在我部署供他人使用时会非常有帮助。

对于AI部分,我使用了OpenAI的API以及Microsoft Azure的OpenAI服务。在这两个API中,我总共使用了三种不同的模型:

  • GPT-4-32k (‘gpt-4-32k’)
  • GPT-4-Turbo (‘gpt-4-1106-preview’)
  • GPT-4-Turbo-Vision (‘gpt-4-vision-preview’)

GPT-4-Turbo模型就像原始的GPT-4,但具有更大的上下文窗口(128k tokens)和更快的速度(高达10倍)。不幸的是,这些改进的代价是:GPT-4-Turbo模型比原始的GPT-4稍微“笨”一些。这在我的爬虫更复杂的阶段成了问题,所以我开始在需要更多智能时使用GPT-4-32K。

GPT-4-32K是原始GPT-4模型的一个变体,上下文窗口为32k而不是4k。(我最终使用Azure的OpenAI服务来访问GPT-4-32K,因为OpenAI目前限制了在他们自己的平台上对那个模型的访问)

开始

我开始从我的约束条件反向工作。由于我在后台使用Playwright爬虫,我知道我最终需要一个页面上的元素选择器,如果我将要与之交互的话。

如果您不熟悉,元素选择器是一个字符串,用于标识页面上的特定元素。如果我想要选择页面上的第四段,我可以使用选择器p:nth-of-type(4)。如果我想要选择带有“点击我”文本的按钮,我可以使用选择器button:has-text('Click Me')。Playwright的工作方式是首先使用选择器识别您想要交互的元素,然后对其执行操作,如“click()”或“fill()”。

鉴于此,我的第一项任务是弄清楚如何从给定的网页中识别“感兴趣的元素”。从这里开始,我将把这个功能称为“GET_ELEMENT”。

获取感兴趣的元素

方法1:屏幕截图 + 视觉模型

HTML数据可能非常复杂和长。它们大部分倾向于专门用于样式、布局和交互逻辑,而不是文本内容本身。我担心文本模型在这种情况下表现不佳,所以我想到了一个办法,那就是使用GPT-4-Turbo-Vision模型简单地“看”渲染后的页面,并从中转录最相关的文本。然后,我可以搜索原始HTML以找到包含该文本的元素。

GET_ELEMENT尝试#1
屏幕截图 ® 视觉模型 正则表达式搜索
oo -一一一人 eg
— > 找到相关文本 > 找到相应的
— 屏幕上 HTML元素
\f

这种方法很快失败了:

首先,GPT-4-Turbo-Vision有时会拒绝我的转录文本请求,说些像“对不起,我无法帮助您。”之类的话。有一次它说“对不起,我无法从版权图片中转录文本。”看起来OpenAI试图阻止它帮助执行这样的任务。(幸运的是,通过提到你是盲人可以绕过这个问题。)

然后是更大的问题:大型页面制作非常高的屏幕截图(> 8,000像素)。这是一个问题,因为GPT-4-Turbo-Vision预先处理所有图像以适应特定尺寸。我发现一个非常高的图像会被压缩得如此严重以至于无法阅读。

解决这个问题的一个可能的方法是分段扫描页面,总结每个部分,然后连接结果。然而,OpenAI对GPT-4-Turbo-Vision的速率限制将迫使我构建一个排队系统来管理这个过程。听起来很头疼。

最后,仅凭文本并不容易逆向工程出一个有效的元素选择器,因为您不知道底层HTML是什么样子的。由于所有这些原因,我决定放弃这种方法。

方法2:HTML + 文本模型

文本仅GPT-4-Turbo的速率限制更宽松,并且有128k上下文窗口,我想我可以尝试直接传递页面的全部HTML,并让它识别相关元素。

[SETELEMENT尝试#2
HTML sy» 文本模型
i. 5 选择最
al 相关的元素
\f

尽管HTML数据(大部分时间)是合适的,我发现GPT-4-Turbo模型只是不够智能,无法正确做到这一点。它们通常会识别错误的元素,或者给我一个太宽泛的选择器。

所以我尝试通过隔离body并删除脚本和样式标签来减少HTML,虽然这有所帮助,但仍然不够。看起来从整个页面中识别“相关”的HTML元素对于语言模型来说太模糊和难以理解了。我需要找到一种方法,只钻取少数几个我可以交给文本模型的元素。

对于这种方法,我决定从人类可能如何处理这个问题中获得灵感。

方法3:HTML + 文本搜索 + 文本模型

如果我在网页上查找特定信息,我会使用’Control’ + 'F’来搜索关键词。如果我的第一次尝试没有找到匹配项,我会尝试不同的关键词,直到找到我要找的内容。

这种方法的好处是简单的文本搜索非常快速和简单实现。在我的情况下,搜索词可以使用文本模型生成,搜索本身可以在HTML上使用简单的正则表达式搜索来执行。

生成术语会比进行搜索慢得多,所以我可以要求文本模型一次生成多个术语,然后同时搜索它们。任何包含搜索词的HTML元素都会被收集起来并传递到下一步,在那里我可以要求GPT-4-32K选择最相关的一个。

搜索词 HTML 正则表达式搜索 \ 文本模型
[定价 “成本”,“费用”, Ha 过滤到 选择最
“付费”,“专业”,“计划”, 一 2 Ero 一 一
“付款”] fen 匹配元素 相关元素
\f

当然,如果您使用足够的搜索词,有时肯定会抓取很多HTML,这可能会触发API限制或影响下一步的性能,所以我设计了一个方案,可以智能地填充一个相关元素列表,直到自定义长度。

我要求Turbo模型提出15-20个按估计相关性排序的术语。然后我会使用简单的正则表达式搜索在HTML中查找页面上包含该术语的每个元素。到这一步结束时,我会有一个列表列表,每个子列表包含匹配给定术语的所有元素:

1: “定价” 2: “费用”
定价

如果|取消将产生费用…

阅读我们的定价页面...

税费和费用之后

定价可能会变... 3: “成本” 4: “价格”
成本分解

然后我会用这些列表中的元素填充最终列表,优先考虑出现在早期列表中的元素。例如,假设排名搜索词是:‘定价’,‘费用’,‘成本’和’价格’。当我填充我的最终列表时,我会确保包括更多的’定价’列表中的元素而不是’费用’列表,更多的’费用’列表中的元素而不是’成本’列表,以此类推。

一旦最终列表达到预定义的令牌长度,我就会停止填充。这样,我可以确保我永远不会超过下一步的令牌限制。

元素列表 最终列表
EBPrncing 查看定价…
定价

阅读我们的定价页面...

定价可能会变...
查看定价更多...
定价可能会变...
查看成本分解... 3: “成本”
成本分解

如果您好奇这个算法的代码是什么样的,这是一个简化版本:

const getElements = async (page, terms, tokenLimit) => {
  let matchingElements: string[][] = [];
  let results: { query: string; matches: number }[] = [];

  // 第一步:获取每个术语的所有匹配元素
  const promises = [];
  for (const term of terms) {
    promises.push(
      (async () => {
        const elements = await page.evaluate(findMatchingElements, { term });
       matchingElements.push(elements.reverse());
        results.push({ query: term, matches: elements.length });
      })()
    );
  }
  await Promise.all(promises);

  let tokenUsage = 0;
  let finalList: string[] = [];

  // 第二步:填充最终列表,优先考虑早期术语
  let iteration = 1;
  const emptyLists = new Set();
  for (let x = matchingElements.length - 1; true; ) {
    if (emptyLists.size === matchingElements.length) break;

    // 获取列表中的最后一个元素
    const lastElement = matchingElements[x].pop();

    if (lastElement && !emptyLists.has(x)) {
      const lastElementTokenCount = tokenCount(lastElement);

      // 如果元素合适,添加它
      if (tokenUsage + lastElementTokenCount < tokenLimit) {
        tokenUsage += lastElementTokenCount;
        finalList.push(lastElement);
      }
    }

    emptyLists.add(x);

    if (x < 0) {
      x = matchingElements.length - 1 - (iteration % matchingElements.length);
      iteration++;
    }
  }

  return finalList;
};

这种方法使我能够以可管理的长度列表结束,该列表代表来自各种搜索词的匹配元素,但优先考虑排名更高的相关术语。

然后遇到了另一个问题:有时您需要的信息不在匹配元素本身,而是在兄弟或父元素中。

假设我的AI正在尝试找出古巴的首都。它会搜索“首都”这个词,并找到这个橙色的元素。问题是我们需要的信息在绿色元素中 - 一个兄弟元素。我们已经接近答案了,但没有包括两个元素,我们将无法解决问题。

为了解决这个问题,我决定在我的元素搜索函数中包括“父级”作为可选参数。将父级设置为0意味着搜索函数将只返回直接包含文本的元素(自然包括该元素的子元素)。

将父级设置为1意味着搜索函数将返回直接包含文本的元素的父元素。将父级设置为2意味着搜索函数将返回直接包含文本的元素的祖父元素,以此类推。在这个古巴的例子中,将父级设置为2将返回整个红色部分的HTML:

我决定将默认父级设置为1。任何更高的设置都可能导致每次匹配抓取大量的HTML。

所以现在我们已经得到了一个可管理的大小列表,带有有用的父级上下文,是时候进入下一步了:我想要让GPT-4-32K从这个列表中选择最相关的元素。

这一步相当直接,但是需要一些试验和错误才能正确地提示:

在这一步之后,我最终得到了页面上最相关的单个元素,然后我可以将这个元素传递到下一步,在那里我将有一个AI模型决定需要完成目标的交互类型。

设置助手

提取相关元素的过程是有效的,但是有点慢和随机。此时我需要的是某种“规划者”AI,它可以看到前一步的结果,并在效果不佳时尝试使用不同的搜索词。

幸运的是,这正是OpenAI的助手API帮助实现的。一个“助手”是一个模型,它包含额外的逻辑,允许它自主操作,使用自定义工具,直到达到目标。您可以通过设置底层模型类型,定义它可以使用的一组工具列表,并向其发送消息来初始化它。

一旦助手运行,您可以通过API轮询检查其状态。如果它决定使用自定义工具,状态将指示它想要使用的工具以及它想要使用的参数。那时,您可以生成工具输出并将其传回助手,以便它可以继续。

对于这个项目,我设置了一个基于GPT-4-Turbo模型的助手,并给了它一个触发我刚刚创建的GET_ELEMENT功能的工具。

这是我为GET_ELEMENT工具提供的描述:

您会注意到,除了最相关的元素外,此工具还返回每个提供的搜索词的匹配元素数量。这些信息帮助助手决定是否需要使用不同的搜索词重试。

有了这个工具,助手现在能够解决我的规格的前两个步骤:分析给定的网页并从任何相关部分提取文本信息。在不需要实际与页面交互的情况下,这就足够了。如果我们想知道产品的价格,并且价格信息包含在我们工具返回的元素中,助手可以简单地返回该元素的文本并完成它。

然而,如果目标需要交互,助手将不得不决定它想要进行什么类型的交互,然后使用另一个工具来执行它。我将这个额外的工具称为’INTERACT_WITH_ELEMENT’

与相关元素交互

为了制作一个与给定元素交互的工具,我本以为我可能需要构建一个自定义API,可以将LLM的字符串响应转换为Playwright命令,但后来我意识到我正在使用模型已经知道如何使用Playwright API(流行的库的好处!)。所以我决定直接以async立即调用函数表达式(IIFE)的形式生成命令。

于是,计划变成了:

助手将提供它想要进行的交互的描述,我将使用GPT-4-32K为该交互编写代码,然后我会在我的Playwright爬虫中执行该代码。

这是我为INTERACT_WITH_ELEMENT工具提供的描述:

您会注意到,助手没有写出完整的元素,而是只提供了一个简短的标识符,这更容易和更快。

以下是我给GPT-4-32K的指示,以帮助它编写代码。我想处理可能有相关信息在页面上,我们需要在与它交互之前提取,所以我告诉它在它的函数内将提取的信息分配给一个名为’actionOutput’的变量。

我将这步的字符串输出 - 我称之为’action’ - 作为参数传入我的Playwright爬虫,并使用’eval’函数将其作为代码执行(是的,我知道这可能很危险):

如果您想知道为什么我不让助手直接提供它的交互代码,那是因为我用于助手的Turbo模型最终太“笨”了,无法可靠地写出命令。所以我让助手描述它想要的交互(“点击这个元素”),然后我使用更强大的GPT-4-32K模型编写代码。

传达页面状态

此时我意识到我需要一种方法来向助手传达页面的状态。我希望它根据它所在的页面制定搜索词,仅仅给出URL感觉不够优化。另外,有时我的爬虫无法正确加载页面,我希望助手能够检测到并重试。

为了获取这些额外的页面上下文,我决定制作一个新功能,使用GPT-4-Vision模型总结页面的前2048像素。我将这个功能插入两个必要的地方:在最开始,以便分析起始页面;以及在INTERACT_WITH_ELEMENT工具的结尾,以便助手能够理解它的交互结果。

有了这个最后的环节,助手现在能够决定给定的交互是否按预期工作,或者是否需要重试。这在遇到验证码或其他弹出窗口的页面上非常有帮助。在这种情况下,助手会知道它必须绕过障碍才能继续。

最终流程

让我们回顾到目前为止的过程:我们从给助手一个URL和一个目标开始。然后助手使用’GET_ELEMENT’工具从页面中提取最相关的元素。

如果交互合适,助手将使用’INTERACT_WITH_ELEMENT’工具编写并执行该交互的代码。它将重复这个流程,直到目标达成。

现在,是时候通过看看它在维基百科上搜索答案的表现如何来进行测试了。

测试助手

我的最终目标是构建一个通用网络爬虫,可以在每个页面上工作,但对于起始测试,我想看看它在维基百科这样可靠的环境下工作得如何,每个页面都包含许多链接到许多其他页面。助手应该能够在这个领域内轻松找到信息。

我给了它美国维基百科页面,并告诉它:“我想知道莫哈韦沙漠的总陆地面积。”

美国页面包含近150万个字符的HTML内容,大致相当于375,000个令牌。所以这是一个很好的测试系统处理大量数据的能力。

正如预期的那样,助手使用了’GET_ELEMENT’工具,但它最初的搜索词很糟糕。这些术语可能太具体了,无法在页面上进行精确匹配:

[TOOL CALL] 🔧 get_element
[REQUIRES_ACTION]
Running 1 functions...
{
  "url": "https://en.wikipedia.org/wiki/United_States",
  "searchTerms": [
    "Mojave Desert link",
    "Mojave link",
    "desert link",
    "link Mojave",
    "link desert"
  ],
  "directive": "Find a link to the Mojave Desert page to get information on its total land area"
}

果不其然,该工具在所有术语中发现了0个匹配项。

Searching for matching elements. Token limit: 10000
Found 0 matching elements (0 tokens)
No matching elements
Sending Tool Output...
{
  "tool_call_id": "call_aZbkE2kM02qjXL7kx5KDdeWb",
  "output": "{\"error\":\"Error: No matching elements found.\"}"
}

所以,助手决定再试一次,这次它使用了很多更通用的术语:

[TOOL CALL] 🔧 get_element
[REQUIRES_ACTION]
Running 1 functions...
{
  "url": "https://en.wikipedia.org/wiki/United_States",
  "searchTerms": [
    "Mojave",
    "Desert",
    "Mojave Desert",
    "geography",
    "landscape",
    "environment",
    "Nature",
    "ecosystem",
    "biome",
    "land",
    "climate",
    "terrain",
    "Landforms",
    "physical"
  ],
  "directive": "Find a link to the Mojave Desert page on Wikipedia from the United States page"
}

该工具在这组术语中找到了134个匹配元素,总计超过300万个令牌(可能是因为返回的元素之间有很多重叠)。幸运的是,前面提到的选择最终元素列表的算法能够将其缩减到41个元素(我将其限制为10,000个令牌)。

然后,GPT-4-32K选择了这个元素作为最相关的,其中包含指向莫哈韦沙漠维基百科页面的链接:

<p>
  <!-- 为了可读性而省略 -->
  <!-- ... -->
  <a href="/wiki/Sonoran_Desert" title="Sonoran Desert">Sonoran</a>, and
  <a href="/wiki/Mojave_Desert" title="Mojave Desert">Mojave</a> deserts.
  <sup id="cite_ref-179" class="reference">
    <a href="#cite_note-179">[167]</a>
  </sup>
  <!-- ... -->
</p>

如果您想知道为什么这个元素包含比链接本身更多的HTML,那是因为我将’parents’参数设置为1,这意味着所有匹配的元素都将与其直接父元素一起返回。

在收到这个元素作为’GET_ELEMENT’工具输出的一部分后,助手决定使用’INTERACT_WITH_ELEMENT’工具尝试点击该链接:

[NEW STEP] 👉 [{"type":"function","name":"interact_with_element"}]
Running 1 function...
{
  "elementCode": "16917",
  "url": "https://en.wikipedia.org/wiki/United_States",
  "directive": "Click on the link to the Mojave Desert page"
}

'INTERACT_WITH_ELEMENT’工具使用GPT-4-32K处理这个想法成为一个Playwright动作:

Running writeAction with azure32k...
Write Action Response:
"(async () => {
  await page.click('p a[href=\"/wiki/Mojave_Desert\"]')();
})();"

我的Playwright爬虫运行了动作,浏览器成功导航到了莫哈韦沙漠页面。

最后,我使用GPT-4-Vision处理了新页面,并将浏览器状态的摘要作为工具输出的一部分发送回助手:

Summarize Status Response:
"We clicked on a link to the Wikipedia page for the Mojave Desert. And now we are looking at the Wikipedia page for the Mojave Desert."

助手决定目标尚未达成,因此在新页面上重复了这个过程。再次,它最初的搜索词太具体了,结果很少。但在第二次尝试中,它提出了这些术语:

[TOOL CALL] 🔧 get_element
[REQUIRES_ACTION]
Running one function...
{
  "url": "https://en.wikipedia.org/wiki/Mojave_Desert",
  "searchTerms": [
    "square miles",
    "square kilometers",
    "km2",
    "mi2",
    "area",
    "acreage",
    "expansion",
    "size",
    "span",
    "coverage"
  ],
  "directive": "Locate the specific section or paragraph that states the total land area of the Mojave Desert on the Wikipedia page"
}

'GET_ELEMENT’工具最初找到了21个匹配项,总计491,000个令牌,缩减到12个。然后GPT-4-32K选择了这12个中最相关的,其中包含搜索词“km2”:

<tr>
  <th class="infobox-label">Area</th>
  <td class="infobox-data">81,000&nbsp;km<sup>2</sup>(31,000&nbsp;sq&nbsp;mi)</td>
</tr>

这个元素对应于渲染页面的这个部分:

在这种情况下,如果我们没有将’parents’设置为1,我们将无法找到这个答案,因为我们正在寻找的答案在匹配元素的兄弟元素中,就像我们的古巴示例一样。

'GET_ELEMENT’工具将元素传回助手,助手正确地注意到信息满足了我们的目标。因此,它完成了运行,让我知道莫哈韦沙漠的总面积是81,000平方公里或31,000平方英里:

[FINAL MESSAGE] ✅ The total land area of the Mojave Desert is 81,000 square kilometers or 31,000 square miles.
{
  "status": "complete",
  "info": {
    "area_km2": 81000,
    "area_mi2": 31000
  }
}

如果您想阅读这次运行的完整日志,可以在这里找到它们的副本!

结束语

构建这个东西我非常享受,并且学到了很多。即便如此,这仍然是一个脆弱的系统。我期待将其提升到下一个级别。以下是我想改进的一些事项:

  • 生成更智能的搜索词,以便更快地找到相关元素
  • 在我的’GET_ELEMENT’工具中实现模糊搜索,以适应文本的轻微变化
  • 使用视觉模型标记HTML中的图像和图标,以便助手可以与它们交互
  • 通过使用住宅代理和其他技术增强爬虫的隐蔽性

最近更新

  1. TCP协议是安全的吗?

    2024-06-19 00:04:02       16 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-06-19 00:04:02       16 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-06-19 00:04:02       15 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-06-19 00:04:02       18 阅读

热门阅读

  1. Shell中执行.sh文件的常见方式

    2024-06-19 00:04:02       8 阅读
  2. 平移矩阵中的数学思考

    2024-06-19 00:04:02       8 阅读
  3. Spring Cloud Gateway 概述与基本配置(上)

    2024-06-19 00:04:02       7 阅读
  4. 从零学习es8

    2024-06-19 00:04:02       6 阅读
  5. Stage模型

    2024-06-19 00:04:02       6 阅读
  6. 正规式理解

    2024-06-19 00:04:02       5 阅读