feat: add MCP server for the website chatbot over a mirrored TYPO3 content feed

This commit is contained in:
fromme committed 2026-10-08 11:46:58 +02:00
1 parent ff7ddf7a9e
commit 1b4384e3f3
52 files changed
+5117 -4

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Model\ContentFeed;
/**
* The complete list of what TYPO3 publishes. An item missing from it has been deleted,
* hidden or unpublished.
*/
final readonly class ContentManifest
{
/**
* @param array<string, ManifestItem> $items keyed by ManifestItem::key()
* @param list<string> $siteHosts hosts of the sites' base URLs, the only ones a contentUrl may point to
*/
public function __construct(
public \DateTimeImmutable $generatedAt,
public array $items,
public array $siteHosts,
) {
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Model\ContentFeed;
/**
* What one content sync run did, for the command output and the log.
*/
final class ContentSyncReport
{
public int $added = 0;
public int $deleted = 0;
public int $records = 0;
public int $pages = 0;
/** Records or pages that failed this run. */
public int $failed = 0;
/** Pages left for a later run by the limit outside the night window. */
public int $deferred = 0;
/** Why the run ended early, with nothing after that point fetched. */
public ?string $stoppedBy = null;
/** Why the page phase ended early; the rest of the run completed. */
public ?string $pagePhaseEndedBy = null;
public function isComplete(): bool
{
return null === $this->stoppedBy && null === $this->pagePhaseEndedBy && 0 === $this->deferred;
}
public function changedAnything(): bool
{
return $this->added + $this->deleted + $this->records + $this->pages > 0;
}
/**
* @return array<string, int|string|null>
*/
public function toLogContext(): array
{
return get_object_vars($this);
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Model\ContentFeed;
use App\Entity\ContentDocument;
/**
* One entry of the TYPO3 content manifest. Identity is `(type, uid, language)`.
*
* `contentUrl`, `breadcrumb` and `detailOf` are set on pages only. `contentUrl` is fetched as
* given and never constructed: the trailing-slash variant silently serves the whole page.
*/
final readonly class ManifestItem
{
/**
* @param list<string> $breadcrumb
* @param list<array{type: string, uid: int}> $detailOf
*/
public function __construct(
public string $type,
public int $uid,
public string $language,
public ?string $site,
public ?string $url,
public \DateTimeImmutable $modified,
public ?string $contentUrl = null,
public ?string $title = null,
public ?string $description = null,
public array $breadcrumb = [],
public array $detailOf = [],
) {
}
/**
* @param array<string, mixed> $item
*/
public static function fromArray(array $item): self
{
if (!is_string($item['type'] ?? null) || !is_int($item['uid'] ?? null) || !is_string($item['language'] ?? null) || !is_string($item['modified'] ?? null)) {
throw new \InvalidArgumentException('Manifest item without type, uid, language or modified');
}
$modified = self::localTime($item['modified']);
if (null === $modified) {
throw new \InvalidArgumentException(sprintf('Manifest item %s:%d has an unparseable modified', $item['type'], $item['uid']));
}
$detailOf = [];
foreach (is_array($item['detailOf'] ?? null) ? $item['detailOf'] : [] as $ref) {
if (is_array($ref) && is_string($ref['type'] ?? null) && is_int($ref['uid'] ?? null)) {
$detailOf[] = ['type' => $ref['type'], 'uid' => $ref['uid']];
}
}
$string = static fn (mixed $value): ?string => is_string($value) && '' !== $value ? $value : null;
return new self(
type: $item['type'],
uid: $item['uid'],
language: $item['language'],
site: $string($item['site'] ?? null),
url: $string($item['url'] ?? null),
modified: $modified,
contentUrl: $string($item['contentUrl'] ?? null),
title: $string($item['title'] ?? null),
description: $string($item['description'] ?? null),
breadcrumb: array_values(array_filter(is_array($item['breadcrumb'] ?? null) ? $item['breadcrumb'] : [], 'is_string')),
detailOf: $detailOf,
);
}
/**
* A feed timestamp in the application's timezone. Doctrine stores datetimes without
* their offset, so a value kept in TYPO3's +02:00 would never compare equal to itself
* once reloaded, and every item would look changed on every run.
*/
public static function localTime(string $value): ?\DateTimeImmutable
{
$date = date_create_immutable($value);
return false === $date ? null : $date->setTimezone(new \DateTimeZone(date_default_timezone_get()));
}
public function key(): string
{
return ContentDocument::key($this->type, $this->uid, $this->language);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Model\ContentFeed;
/**
* Outcome of fetching one page's contentUrl.
*
* A 404 or 5xx is a failure of that page, remembered against its `modified`. A timeout is
* transient: the page keeps its stored version and is retried next run.
*/
final readonly class PageFetchResult
{
private function __construct(
public ?string $html,
public ?int $failedStatus,
public bool $transient,
) {
}
public static function ok(string $html): self
{
return new self($html, null, false);
}
public static function failed(int $status): self
{
return new self(null, $status, false);
}
public static function transient(): self
{
return new self(null, null, true);
}
public function isOk(): bool
{
return null !== $this->html;
}
}