Drupal 11构建链接目录:设置、截图截取与表单创建全攻略

Drupal 11构建链接目录:设置、截图截取与表单创建全攻略
以下是清洗后的 HTML 内容,满足 HTML 标签关闭且合理区分内容,标题使用 `h3` 标签的要求:

Drupal 11: Building A Link Directory: Part 1

一段时间以来,我一直在为管理书签而苦恼。每当我遇到一篇想读的有趣文章、一个想保存的优质资源,或者一个想尝试的实用工具时,我都会创建一个书签。

随着时间的推移,我积累了大量的书签,所以当我在列表中添加一个新书签时,它就会淹没在众多书签之中。我曾尝试创建目录来存放“新”书签,或者将它们分类整理,但最终总是难以找到所需的书签。

问题在于,网页浏览器不允许对书签进行分类或搜索,所以我再也找不到它们了。而且,当我更换浏览器(今年我已经换了两次)时,我不得不迁移书签并在不同计算机之间设置同步。这总会导致网站的图标丢失,让我更难找到正确的链接。

最近,我又丢失了一个书签,于是决定采取行动。我意识到 #! code 是存放书签的最佳选择,因为我总是登录该网站,所以我开始在该网站上创建一个链接目录。不过,我并不只想创建一个简单的链接列表。在我看来,一个好的链接目录应该在创建链接时截取网站的截图,这样通过原始网站的截图就能轻松查看有哪些链接。

在本文中,我将介绍如何设置链接目录、如何添加链接,以及网站如何在链接添加到目录时截取链接的截图。

创建链接内容类型

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

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

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

截取网站截图

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

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

这个包能够使用无头 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 横幅遮挡。我将在另一篇文章中探讨如何解决这些限制。

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

A screenshot of the Mechboards website, a popular keyboard webiste. It is showing the new Corne Min keyboard on the homepage. A black, two part, keyboard on a yellow background.

让我们看看如何将截图生成与链接实体关联起来。

链接钩子事件

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

这里最好使用 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;

    /**
     * Hooks for the link node.
     */
    class LinkHooks {

      use StringTranslationTrait;

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

      /**
       * Implements hook_node_presave().
       *
       * Perform actions on the link as it is saved.
       */
      #[Hook('node_presave')]
      public function linkUpdate(NodeInterface $node):void {
        if (!($node instanceof LinkInterface)) {
          // Only act upon Link nodes.
          return;
        }

        if ($node->isNew()) {
          // Don't act upon new nodes.
          return;
        }

        if ($node->get('field_link_url')->isEmpty()) {
          // Don't run this process for empty links.
          return;
        }

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

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

        if ($result === NULL) {
          // Crawler failed, don't update the Link.
          return;
        }

        if ($result->getResponseCode() === 403) {
          // We can't update this as it's protected, so just ignore it.
          return;
        }

        if ($result->getResponseCode() !== 200) {
          // The site was down or something.
          $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) {
          // Don't update the image.
          return;
        }

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

        if ($file instanceof FileInterface) {
          // If we have a file then inject this into the page.
          $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 获取数据,然后创建一个链接对象。如果我们有截图数据,我们将

联系我们

提供基于Drupal的门户网站、电子商务网站、移动应用开发及托管服务

长按加微信
长风云微信
长按关注公众号
长风云公众号