Drupal 11:带截图的链接目录构建分步指南

Drupal 11:构建链接目录:第 1 部分

一段时间以来,我公司一直为管理书签的问题所困扰。每当遇到一篇想阅读的有趣文章、一个想保存的优质资源,或者一个想尝试的实用工具时,都会创建一个书签。

随着时间的推移,积累了大量的书签,以至于在列表中添加新书签时,它就会淹没在众多书签之中。我公司曾尝试创建目录来存放“新”书签,或者将它们分类整理,但最终总是手忙脚乱地找不到它们。

问题在于,网页浏览器不允许对书签进行分类或搜索,所以再也找不到它们了。此外,当更换浏览器时(今年已经换了两次),最终不得不迁移书签,并在不同计算机之间设置同步。这总是会删除网站的图标,所以在寻找正确链接时会遇到更多麻烦。

最近又丢失了一个书签后,我公司决定采取行动。意识到 Drupal 开发中,#! code 是存放书签的最佳地方,因为总是登录该网站,所以着手在网站上创建一个链接目录。不过,不只是想要一个长长的链接列表。在我公司看来,一个好的链接目录应该在创建链接时截取网站的截图,这样通过原始网站的截图就可以轻松看到有哪些链接。

在这篇文章中,我公司将介绍是如何设置链接目录的,如何添加链接,以及当链接添加到目录中时,网站是如何截取链接截图的。

一、创建链接内容类型

为了存储链接,我公司创建了一个名为“Link”的内容类型,并为其添加了几个字段。

  • URL - 这使用链接字段类型来存储正在使用的 URL。通过字段设置,我公司禁用了标题字段,并要求必须是外部链接。链接字段小部件意味着我们可以对表单进行一些基本的验证,尽管我们不会过多使用这一点。
  • 标签 - 我们可以为链接使用的一些标签。这有助于对它们进行分类。
  • 图片 - 一个用于存储链接截图的图片字段。之所以使用这个字段而不是媒体字段,是因为我们希望图片直接与链接项关联,而不需要在其他地方共享。
  • 是否关闭图片更新? - 这是一个布尔字段,可以用来防止更新截图。这使我们能够以多种不同方式生成截图,但如果截图不是自动更新的,就可以防止其被更新。
  • 描述 - 为了为系统搜索提供上下文,添加了一个简短的描述字段。这在未来浏览链接目录时也会有帮助,特别是如果该网站此后已离线。
  • 最后检查日期 - 考虑到网站可能会离线,使用一个时间戳字段来存储链接最后一次被检查的时间。这将在未来用于检查网站中的链接,以确保它们仍然在线。

内容类型设置好后,我公司还创建了一个视图,以便在目录中显示链接。使用了一个简单的网格布局,将带有截图的链接以网格形式显示。

二、截取网站截图

现在我们有了一个存储链接的地方,接下来需要考虑如何截取网站的截图,使用 PHP 来做这件事相对简单。不过,要正确设置好所有内容,还是需要做一些工作。

在过去的几年里,我公司一直在进行 Drupal 模块开发,开发了一个 PHP 网站地图检查器包,它使用几种不同的引擎从 sitemap.xml 文件中抓取链接并检查其有效性。我公司经常使用这个工具来检查 sitemap.xml 文件,并扫描网站中的 broken 链接。

这个包能够使用无头 Chrome 浏览器(通过 Chrome PHP 包)来扫描并(可选)截取相关页面的截图。我公司对这个系统进行了调整,使其能够使用 Chromium 而不是 Chrome 来扫描并截取单个页面的截图。Chromium 是 Chrome 的基础包,因此比 Chrome 具有更少的跟踪和其他功能。这使得 Chromium 非常适合用于此目的。

扫描页面的过程使用以下步骤:

  • 创建一个 \Hashbangcode\SitemapChecker\Url\Url 对象。这个对象的构造函数接受一个参数,即我们要测试的 URL。该对象会将 URL 拆分为相关部分(协议、主机、路径等),以便对其进行验证并与类似链接进行标准化处理。例如,搜索页面可能是一个有效的 URL,但有很多只是页码不同的搜索页面副本,这是我公司想要识别出来的情况。
  • 创建一个 \Hashbangcode\SitemapChecker\Crawler\ChromeCrawler 对象,它是 URL 处理系统的包装器,允许处理链接并将其转换为结果。
  • 然后我们创建一个 \HeadlessChromium\BrowserFactory 对象,它是爬虫和 Chromium 实例之间的桥梁。这个对象的构造函数接受一个字符串,即 Chrome/Chromium 可执行文件的位置。我公司下载了 Chromium 的二进制文件并将其放在服务器上,其位置通过一个配置选项进行设置。我公司在这里没有展示这一点,但你应该能明白。
  • 为 Chromium 设置选项,以设置特定的窗口大小和其他一些选项,使 Chromium 能够以无头模式运行。这使我们能够启动 Chromium 并截取截图,而不会遇到诸如没有 cookie 之类的问题。我们也不关心浏览器崩溃的情况,因此为此设置了几个选项。
  • 然后我们将爬虫的引擎设置为 Chromium 浏览器,这样在处理 URL 时就会使用 Chromium。
  • ChromeCrawler 对象中的两个设置是截取截图的选项以及从截取截图操作返回的数据类型。在我们的例子中,我们希望将截图作为数据返回,而不是将截图保存为文件。这意味着我们可以使用 Drupal 核心服务将其注入到 Drupal 文件实体中。

以下是运行上述所有步骤的代码。

  
    $urlObj = new Url($url);
    $crawler = new ChromeCrawler();
    $browserFactory = new BrowserFactory('/path/to/the/chrome/binary');

    $options = [
      'windowSize' => [1280, 960],
      'noSandbox' => TRUE,
      'userDataDir' => sys_get_temp_dir(),
      'startupTimeout' => '120',
      'envVariables' => [
        'XDG_CONFIG_HOME' => sys_get_temp_dir(),
        'XDG_CACHE_HOME' => sys_get_temp_dir(),
      ],
      'customFlags' => [
        '--disable-crash-reporter',
        '--no-crashpad',
      ],
    ];
    $browserFactory->setOptions($options);

    try {
      $crawler->setEngine($browserFactory->createBrowser());
    }
    catch (\RuntimeException $e) {
      return NULL;
    }

    $crawler->setTakeScreenshot(TRUE);
    $crawler->setScreenshotType(ChromeCrawler::SCREENSHOT_TYPE_DATA);

    $result = $crawler->processUrl($urlObj);
  

这里的 $result 变量包含了从 URL 查找生成的关于页面的所有信息,包括标题、描述、头部信息和页面大小。如果请求一切顺利,这个对象还包含页面的截图,以 base64 编码的图像形式存在,我们可以用它来生成物理文件并创建文件实体。

在通过检查响应代码确保结果实际上是一个有效页面后,我们就可以生成文件实体。这是通过 file_system 服务来确保我们的文件有正确的目录,以及 file.repository 服务根据我们拥有的图像数据创建文件实体来完成的。

我公司之前在一篇文章中写过关于在 Drupal 中将 base64 编码的数据转换为文件实体的内容。这种方法遵循了一些相同的步骤,但我公司从不同的位置获取数据。

以下是从 base64 编码的数据创建文件的代码。

  
    $fileData = $result->getScreenshotFileData();
    if (is_string($fileData) && $fileData !== '') {
      $fileData = base64_decode($fileData);
      $directory = self::LINK_IMAGE_DIRECTORY;
      if ($this->fileSystem->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY)) {
        $fileName = $directory . '/' . $result->getUrl()->generateHash() . '.png';
        $file = $this->fileRepository->writeData($fileData, $fileName, FileExists::Replace);
      }
    }
  

此时,如果一切顺利,$file 变量现在包含了我们可以附加到链接实体并保存到数据库的文件实体。所有这些代码都封装在一个服务中,所以我们要获取截图数据所需要做的就是用我们要测试的 URL 调用这个服务,如下所示。

  
    $result = $this->crawlerService->crawlUrl($url);
  

然后我们可以使用该结果对象生成文件实体。

  
    $file = $this->crawlerService->writeResultsFileDataToDisk($result);
  

然后可以使用以下代码将其添加到链接节点中。

  
    $node->set('field_link_image', [
      'target_id' => $file->id(),
      'alt' => $node->getTitle(),
    ]);
  

使用 Chromium 从 URL 创建截图效果相当不错。不过,在自动截取网站截图时存在一些限制,特别是在当前滥用 AI 爬虫的环境下。很多网站会屏蔽结果,或者直接阻止你,即使你成功获取了截图,也可能会被 cookie 横幅覆盖。我公司将在另一篇文章中探讨如何解决其中一些限制。

以下是该网站的一个截图示例。

让我们来看看如何将这个截图生成功能集成到链接实体中。

三、链接钩子事件

将这段代码注入链接实体的第一种方法是使用钩子。

这里最好使用的钩子是 hook_node_presave() 钩子,它允许我们在链接实体保存到数据库之前更新其中的数据。由于我公司使用的是 Drupal 11,我公司可以使用新的面向对象的钩子系统来添加这个钩子。

以下是包含预保存钩子的完整类,该钩子用于更新链接的图片和最后检查时间戳。我们还使用“field_link_image_update”字段中的设置,以确保在更新链接图片之前我们确实想要自动更新。

请注意,我们在其他地方为链接设置了一个实体捆绑类,所以我们只需要确保传入的实体类型正确,然后再继续操作。

  
    <?php

    namespace Drupal\link_directory\Hook;

    use Drupal\Core\Hook\Attribute\Hook;
    use Drupal\Core\Messenger\MessengerInterface;
    use Drupal\Core\StringTranslation\StringTranslationTrait;
    use Drupal\file\FileInterface;
    use Drupal\link_directory\Entity\Link;
    use Drupal\link_directory\Entity\LinkInterface;
    use Drupal\link_directory\Service\CrawlerServiceInterface;
    use Drupal\node\NodeInterface;

    /**
     * 链接节点的钩子。
     */
    class LinkHooks {

      use StringTranslationTrait;

      public function __construct(
        protected CrawlerServiceInterface $crawlerService,
        protected MessengerInterface $messenger,
      ) {}

      /**
       * 实现 hook_node_presave()。
       *
       * 在保存链接时执行操作。
       */
      #[Hook('node_presave')]
      public function linkUpdate(NodeInterface $node):void {
        if (!($node instanceof LinkInterface)) {
          // 仅对链接节点执行操作。
          return;
        }

        if ($node->isNew()) {
          // 不对新节点执行操作。
          return;
        }

        if ($node->get('field_link_url')->isEmpty()) {
          // 不要对空链接运行此过程。
          return;
        }

        $url = $node->get('field_link_url')->getValue();
        $url = $url[0]['uri'];

        $result = $this->crawlerService->crawlUrl($url);

        if ($result === NULL) {
          // 爬虫失败,不更新链接。
          return;
        }

        if ($result->getResponseCode() === 403) {
          // 由于受保护,我们无法更新此链接,所以忽略它。
          return;
        }

        if ($result->getResponseCode() !== 200) {
          // 网站已关闭或出现其他问题。
          $this->messenger->addError($this->t('The URL %url returned an incorrect status and so was not updated.', ['%url' => $url]));
          return;
        }

        if ($node->hasField('field_link_last_checked')) {
          $node->set('field_link_last_checked', time());
        }

        $imageUpdateOff = (bool) $node->get('field_link_image_update')->getValue()[0]['value'];

        if ($imageUpdateOff === TRUE) {
          // 不更新图片。
          return;
        }

        $file = $this->crawlerService->writeResultsFileDataToDisk($result);

        if ($file instanceof FileInterface) {
          // 如果我们有文件,则将其注入页面。
          $node->set('field_link_image', [
            'target_id' => $file->id(),
            'alt' => $node->getTitle(),
          ]);
        }
      }

    }
  

请注意,这里的钩子不会保存节点,这是有意为之。预保存钩子用于在实体保存到数据库之前更新其中的数据,所以我们只需添加我们想要保存的数据,然后继续。如果我们在这里保存节点,保存操作会触发另一次对 hook_node_presave() 钩子的调用,而由于该钩子会保存节点,这又会触发对该钩子的另一次调用,如此循环往复。

我公司使用一个单独的字段来记录最后检查时间,因为我公司希望它与节点的创建/更新时间不同。由于只有在链接处于活动状态时才会更新最后检查时间,我们可以使用这个字段来自动扫描链接,并检查哪些链接不再活动。我公司将在本系列的后续文章中讨论自动链接检查,但目前这个字段使用得不多。

有了这些设置,当页面保存时(假设链接配置正确),链接的数据和截图就会更新。我公司希望能轻松地在网站上添加链接,所以决定构建一个表单来实现这一点。

四、创建链接提交表单

虽然我公司可以轻松地进入内容创建对话框来创建新内容,但我公司希望有一种方法可以通过简单地将一个 URL 输入到表单中,就在链接目录中创建链接。这样就可以创建链接内容项,截取网站的截图,并将所有内容保存到数据库中。

我公司不会在这里发布整个表单类,但这里构建表单的步骤非常简单,我们只需要一个 URL 字段和一个提交按钮。

  
    public function buildForm(array $form, FormStateInterface $form_state) {
      $form['url'] = [
        '#type' => 'textfield',
        '#title' => $this->t('URL'),
        '#required' => TRUE,
        '#default_value' => $form_state->getValue('url'),
        '#description' => $this->t('This form will strip out any query parameters and fragments from the URL.'),
      ];

      $form['submit'] = [
        '#type' => 'submit',
        '#value' => $this->t('Submit'),
      ];

      return $form;
    }
  

这个表单的验证处理程序相当复杂,但主要重点是确保输入的 URL 有效,并且不包含任何查询字符串或片段。对于链接目录,我们实际上只对主机和路径感兴趣,因为查询字符串要么包含跟踪信息,要么包含用于更改当前页面的参数。验证处理程序还会检查链接是否已经存在于数据库中,这样我们就不会创建重复的项。一旦输入的 URL 经过充分验证,就会对其进行格式化并存储在表单状态中,供提交处理程序使用。

当表单提交时,我们需要使用爬虫服务从输入的 URL 获取数据,然后创建一个链接对象。如果我们有截图数据,就用该信息更新链接并将其保存到数据库中。最后一步是使节点列表的任何缓存失效,这样链接就会作为一个项出现在我们的链接目录中。

  
    public function submitForm(array &$form, FormStateInterface $form_state) {
      $ignore404 = (bool) $form_state->getValue('ignore_404');
      $url = $form_state->getValue('url');

      $result = $this->crawler->crawlUrl($url);

      if ($result === NULL) {
        $this->messenger()->addError($this->t('Unable to start the Chrome instance.'));
        return;
      }

      if ($result->getResponseCode() === 403) {
        $headers = $result->getHeaders();
        if (isset($headers['cf-mitigated'][0]) && $headers['cf-mitigated'][0] === 'challenge') {
          $this->messenger()->addWarning($this->t('Entered URL %url returned an Cloudflare challenge, you will need to do this by hand.', ['%url' => $url]));
          return;
        }
      }

      if ($result->getResponseCode() !== 200) {
        $this->messenger()->addError($this->t('Entered URL %url returned an incorrect status and so was not entered.', ['%url' => $url]));
        return;
      }

      $title = $result->getTitle();
      if ($title === NULL || $title === '') {
        // 为偶尔没有标题的网站设置默认标题为 URL。
        $title = $url;
      }

      // 创建节点。
      $node = Node::create([
        'type' => 'link',
        'title' => $title,
        'field_link_url' => [
          'uri' => $url,
        ],
      ]);

      $node->save();

      $file = $this->crawlerService->writeResultsFileDataToDisk($result);

      if ($file instanceof FileInterface) {
        $node->