如何构建构建一个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: "成本"
成本分解

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

javascript 复制代码
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'工具,但它最初的搜索词很糟糕。这些术语可能太具体了,无法在页面上进行精确匹配:

json 复制代码
[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个匹配项。

json 复制代码
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.\"}"
}

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

json 复制代码
[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选择了这个元素作为最相关的,其中包含指向莫哈韦沙漠维基百科页面的链接:

html 复制代码
<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'工具尝试点击该链接:

json 复制代码
[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动作:

javascript 复制代码
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."

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

json 复制代码
[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":

html 复制代码
<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平方英里:

json 复制代码
[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中的图像和图标,以便助手可以与它们交互
  • 通过使用住宅代理和其他技术增强爬虫的隐蔽性
相关推荐
Marst Code6 分钟前
(Django)初步使用
后端·python·django
龙的爹23337 分钟前
论文 | Model-tuning Via Prompts Makes NLP Models Adversarially Robust
人工智能·gpt·深度学习·语言模型·自然语言处理·prompt
工业机器视觉设计和实现20 分钟前
cnn突破四(生成卷积核与固定核对比)
人工智能·深度学习·cnn
985小水博一枚呀24 分钟前
【对于Python爬虫的理解】数据挖掘、信息聚合、价格监控、新闻爬取等,附代码。
爬虫·python·深度学习·数据挖掘
立秋678935 分钟前
Python的defaultdict详解
服务器·windows·python
萧鼎1 小时前
Python第三方库选择与使用陷阱避免
开发语言·python
白拾1 小时前
使用Conda管理python环境的指南
开发语言·python·conda
我算是程序猿1 小时前
用AI做电子萌宠,快速涨粉变现
人工智能·stable diffusion·aigc
萱仔学习自我记录1 小时前
微调大语言模型——超详细步骤
人工智能·深度学习·机器学习
是刃小木啦~2 小时前
三维模型点云化工具V1.0使用介绍:将三维模型进行点云化生成
python·软件工程·pyqt·工业软件