Drupal 11: Building A Link Directory: Part 1
一段时间以来,我一直在为管理书签而苦恼。每当我遇到一篇想阅读的有趣文章、一个想保留的优质资源,或者一个想尝试的实用工具时,我都会创建一个书签。
随着时间的推移,我积累了大量的书签,所以当我向列表中添加一个新书签时,它就会淹没在书堆中。我曾尝试创建目录来存放“新”书签,或者将它们按部分整理,但最终总是要费劲地去寻找它们。
问题在于,网页浏览器不允许对书签进行分类或搜索,所以我再也找不到它们了。而且,当我更换浏览器时(今年我已经换了两次),我不得不迁移书签并设置计算机之间的同步。这总是会移除网站的图标,所以我在寻找正确的链接时会遇到更多麻烦。
最近又丢失了一个书签后,我决定对此采取行动。我意识到#! code是存放书签的最佳位置,因为我总是登录该网站,所以我开始在该网站上创建一个链接目录。不过,我不只是想要一个长长的链接列表。在我看来,一个好的链接目录会在创建链接时截取网站的截图,这样通过原始网站的截图就可以轻松查看有哪些链接。
在这篇文章中,我将介绍我是如何设置链接目录的,如何添加链接,以及网站如何在链接添加到目录时截取链接的截图。
创建链接内容类型
为了存储链接,我创建了一个名为“链接”的内容类型,并为其添加了几个字段。
- URL - 这使用链接字段类型来存储使用的 URL。通过字段设置,我禁用了标题字段,并要求使用外部链接。链接字段小部件意味着我们可以对表单进行一些基本的验证,尽管我们不会过多使用它。
- 标签 - 一些可以用于链接的标签。这有助于对它们进行分类。
- 图像 - 一个图像字段,用于存储链接的截图。这比使用媒体字段更合适,因为我们希望图像直接与链接项关联,而不需要在其他地方共享。
- 是否关闭图像更新? - 这是一个布尔字段,可用于防止更新截图。这允许我们以多种不同的方式生成截图,但如果不是自动更新,则可以防止其被更新。
- 描述 - 为了为系统搜索提供上下文,添加了一个简短的描述字段。这在将来浏览链接目录时也会有帮助,特别是如果该网站此后已离线。
- 最后检查日期 - 考虑到网站可能会离线,使用一个时间戳字段来存储链接最后一次被检查的时间。这将在未来用于检查网站中的链接,以确保它们仍然在线。
内容类型设置好后,我还创建了一个视图,以便在目录中显示链接。我使用了一个简单的网格设置,将带有截图的链接显示为网格。
截取网站截图
现在我们有了一个存储链接的地方,接下来需要考虑如何截取网站的截图,使用 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 横幅覆盖。我将在另一篇文章中讨论解决其中一些限制的方法。
以下是该网站截图的一个示例。

让我们来看看如何将这个截图生成功能集成到链接实体中。
链接钩子事件
将此代码注入链接实体的第一种方法是使用钩子。
这里最好使用 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 经过全面验证,它就会被格式化并存储在表单状态中,供提交处理程序使用。
### 说明: 1

