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

+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp;
/**
* The site identifiers of the TYPO3 content feed, offered to the model as the `site` filter.
* ep-events and unichamp are retired and not in the feed.
*/
final class Sites
{
public const IDENTIFIERS = ['ep-reisen', 'ser', 'ski-boarderweek', 'ski-wochenenden', 'snowzone'];
public const DESCRIPTION = 'Restrict to one website: ep-reisen (ep-reisen.de), ser (semesterende-skireisen.de), ski-boarderweek, ski-wochenenden, snowzone (snowzone.net).';
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp\Tool;
use App\Chatbot\Mcp\ToolCallRunner;
use App\Chatbot\Search\ContentSearch;
use App\Repository\ContentDocumentRepository;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\ToolAnnotations;
#[McpTool(
name: 'get_document',
description: 'Read the full text of one search_content hit, by its url.',
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
)]
final class GetDocumentTool
{
private const BODY_LIMIT = 20000;
public function __construct(
private readonly ContentDocumentRepository $repository,
private readonly ToolCallRunner $runner,
) {
}
public function __invoke(
#[Schema(description: 'A url returned by search_content.', minLength: 1)]
string $url,
): CallToolResult {
return $this->runner->run('get_document', get_defined_vars(), function () use ($url): array {
$document = $this->repository->findOneIndexedByUrl(trim($url));
if (null === $document) {
throw new ToolCallException('No document at this url. Use a url returned by search_content.');
}
$body = $document->getBodyMd();
$truncated = mb_strlen($body) > self::BODY_LIMIT;
return [
'type' => ContentSearch::publicType($document->getType()),
'title' => $document->getTitle(),
'url' => $document->getUrl(),
'site' => $document->getSite(),
'breadcrumb' => $document->getBreadcrumb(),
'body' => $truncated ? mb_substr($body, 0, self::BODY_LIMIT) : $body,
'truncated' => $truncated,
];
});
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp\Tool;
use App\Chatbot\Mcp\ToolCallRunner;
use App\Chatbot\TripCatalog;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\ToolAnnotations;
#[McpTool(
name: 'get_trip',
description: 'Full details of one trip: description, included services, programme, hotel, all upcoming dates with rooms, prices per person in EUR and availability, pickup points and departure times, FAQs. Use after search_trips, or when the user names a specific trip. Prices and availability are a snapshot as of dataAsOf; the booking page is binding.',
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
)]
final class GetTripTool
{
public function __construct(
private readonly TripCatalog $catalog,
private readonly ToolCallRunner $runner,
) {
}
public function __invoke(
#[Schema(description: 'Trip code, as returned by search_trips.', minLength: 1)]
string $code,
#[Schema(description: 'Narrows the dates to one hotel when a trip has several.')]
?string $hotelCode = null,
): CallToolResult {
return $this->runner->run('get_trip', get_defined_vars(), function () use ($code, $hotelCode): array {
$trip = $this->catalog->get(trim($code), $hotelCode);
if (null === $trip) {
throw new ToolCallException(sprintf('No trip with code "%s". Use search_trips to find trip codes.', $code));
}
return $trip;
});
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp\Tool;
use App\Chatbot\Mcp\Sites;
use App\Chatbot\Mcp\ToolCallRunner;
use App\Chatbot\Search\ContentSearch;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\ToolAnnotations;
#[McpTool(
name: 'search_content',
description: "Search the websites' editorial content: destinations, hotels, travel information, FAQs, terms, blog articles, general company information. Use for questions that are not about specific dates or prices. Returns excerpts; use get_document for the full text of a hit.",
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
)]
final class SearchContentTool
{
public function __construct(
private readonly ContentSearch $search,
private readonly ToolCallRunner $runner,
) {
}
public function __invoke(
#[Schema(description: 'What to look for, in German keywords works best, e.g. "Gepäck Snowboard Bus".', minLength: 1)]
string $query,
#[Schema(description: 'Restrict to one kind of content. destination = region, country or town.', enum: ['page', 'product', 'hotel', 'destination', 'concept', 'news'])]
?string $type = null,
#[Schema(description: Sites::DESCRIPTION, enum: Sites::IDENTIFIERS)]
?string $site = null,
#[Schema(description: 'Number of hits, default 5, at most 8.', minimum: 1, maximum: 8)]
?int $limit = null,
): CallToolResult {
return $this->runner->run('search_content', get_defined_vars(), function () use ($query, $type, $site, $limit): array {
if ('' === trim($query)) {
throw new ToolCallException('query must not be empty.');
}
return ['results' => $this->search->search($query, $type, $site, max(1, min(8, $limit ?? 5)))];
});
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp\Tool;
use App\Chatbot\Mcp\Sites;
use App\Chatbot\Mcp\ToolCallRunner;
use App\Chatbot\TripCatalog;
use App\Model\Chatbot\TripCriteria;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\ToolAnnotations;
#[McpTool(
name: 'search_trips',
description: 'Find bookable trips by structured criteria: travel period, destination, trip type, price, group size. Use for any question about which trips exist, when, where, or how much. Returns trips with their next dates and lowest prices per person in EUR. Prices and availability are a snapshot as of dataAsOf; the booking page is binding.',
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
)]
final class SearchTripsTool
{
public function __construct(
private readonly TripCatalog $catalog,
private readonly ToolCallRunner $runner,
) {
}
public function __invoke(
#[Schema(description: 'Free text matched against trip, hotel, destination and concept names and texts, e.g. "Val Thorens" or "Snowboardkurs".')]
?string $query = null,
#[Schema(description: 'Start of the travel period, YYYY-MM-DD. Trips overlapping the period match.', format: 'date')]
?string $dateFrom = null,
#[Schema(description: 'End of the travel period, YYYY-MM-DD.', format: 'date')]
?string $dateTo = null,
#[Schema(description: 'Country name, e.g. "Frankreich".')]
?string $country = null,
#[Schema(description: 'Region, ski area or town name, e.g. "Les 3 Vallées".')]
?string $region = null,
#[Schema(description: 'Trip concept code or name, e.g. ski, party, group.')]
?string $concept = null,
#[Schema(description: 'Maximum price per person in EUR.', minimum: 0)]
?float $maxPrice = null,
#[Schema(description: 'Only dates with at least this many places available.', minimum: 1)]
?int $persons = null,
#[Schema(description: 'true for day trips only, false to exclude them.')]
?bool $daytrip = null,
#[Schema(description: Sites::DESCRIPTION, enum: Sites::IDENTIFIERS)]
?string $site = null,
#[Schema(description: 'Number of trips, default 5, at most 10.', minimum: 1, maximum: 10)]
?int $limit = null,
): CallToolResult {
return $this->runner->run('search_trips', get_defined_vars(), function () use ($query, $dateFrom, $dateTo, $country, $region, $concept, $maxPrice, $persons, $daytrip, $site, $limit): array {
$from = $this->date($dateFrom, 'dateFrom');
$to = $this->date($dateTo, 'dateTo');
if (null !== $from && null !== $to && $from > $to) {
throw new ToolCallException('dateFrom must not be after dateTo.');
}
return $this->catalog->search(new TripCriteria(
query: $this->text($query),
dateFrom: $from,
dateTo: $to,
country: $this->text($country),
region: $this->text($region),
concept: $this->text($concept),
maxPrice: $maxPrice,
persons: $persons,
daytrip: $daytrip,
site: $site,
limit: max(1, min(10, $limit ?? 5)),
));
});
}
private function date(?string $value, string $name): ?\DateTimeImmutable
{
if (null === $value || '' === trim($value)) {
return null;
}
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', trim($value));
if (false === $date || $date->format('Y-m-d') !== trim($value)) {
throw new ToolCallException(sprintf('%s must be a date in the form YYYY-MM-DD.', $name));
}
return $date;
}
private function text(?string $value): ?string
{
return null === $value || '' === trim($value) ? null : trim($value);
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Result\CallToolResult;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\RateLimiter\RateLimiterFactory;
/**
* What every chatbot tool call goes through: the per-token rate limit, the call log, and
* the answer as compact JSON.
*
* The SDK would pretty-print an array result and repeat it as structured content; both
* cost the model tokens and neither helps n8n. The log records tool, arguments, result
* count and duration, never anything about the person chatting, which n8n does not send.
*/
class ToolCallRunner
{
public function __construct(
private readonly RateLimiterFactory $chatbotMcpLimiter,
private readonly Security $security,
private readonly LoggerInterface $chatbotLogger,
) {
}
/**
* @param array<string, mixed> $arguments as received, for the log
* @param callable(): array<string, mixed> $call
*
* @throws ToolCallException invalid input or rate limit; the model sees the message
*/
public function run(string $tool, array $arguments, callable $call): CallToolResult
{
$arguments = array_filter($arguments, static fn (mixed $value): bool => null !== $value);
$limit = $this->chatbotMcpLimiter->create($this->security->getUser()?->getUserIdentifier() ?? 'anonymous')->consume();
if (!$limit->isAccepted()) {
$this->chatbotLogger->warning('MCP tool call rate-limited', ['tool' => $tool, 'arguments' => $arguments]);
throw new ToolCallException(sprintf('Too many tool calls. Retry after %s.', $limit->getRetryAfter()->format('H:i:s')));
}
$started = hrtime(true);
try {
$result = $call();
} catch (ToolCallException $e) {
$this->chatbotLogger->info('MCP tool call rejected', ['tool' => $tool, 'arguments' => $arguments, 'error' => $e->getMessage()]);
throw $e;
} catch (\Throwable $e) {
$this->chatbotLogger->error('MCP tool call failed', ['tool' => $tool, 'arguments' => $arguments, 'exception' => $e]);
throw $e;
}
$this->chatbotLogger->info('MCP tool call', [
'tool' => $tool,
'arguments' => $arguments,
'resultCount' => is_array($result['results'] ?? null) ? count($result['results']) : 1,
'durationMs' => (int) ((hrtime(true) - $started) / 1_000_000),
]);
return new CallToolResult([new TextContent(json_encode($result, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_INVALID_UTF8_SUBSTITUTE))]);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Search;
use App\Repository\ContentDocumentRepository;
/**
* Full-text search over the mirrored website content.
*
* Ranked by relevance, weighted by type in the order people need them: what they book
* (products, hotels), where they go (destinations, concepts), general pages, then blog
* posts. The weights are multipliers on the relevance score, so a much better match of a
* lower type still wins.
*/
class ContentSearch
{
/**
* The tool's `type` values. `destination` covers region, country and city.
*/
public const TYPES = [
'page' => ['page'],
'product' => ['product'],
'hotel' => ['hotel'],
'destination' => ['region', 'country', 'city'],
'concept' => ['concept'],
'news' => ['news'],
];
private const TYPE_WEIGHTS = [
'product' => 1.4,
'hotel' => 1.4,
'region' => 1.2,
'country' => 1.2,
'city' => 1.2,
'concept' => 1.2,
'page' => 1.0,
'news' => 0.8,
];
public function __construct(private readonly ContentDocumentRepository $repository)
{
}
/**
* @param string|null $type a key of TYPES
*
* @return list<array{type: string, title: string, url: string, site: ?string, breadcrumb: ?string, excerpt: string}>
*/
public function search(string $query, ?string $type, ?string $site, int $limit): array
{
$booleanQuery = ContentSearchQuery::booleanQuery($query);
if (null === $booleanQuery) {
return [];
}
$rows = $this->repository->searchFulltext($booleanQuery, null === $type ? [] : self::TYPES[$type], $site, self::TYPE_WEIGHTS, $limit);
$terms = ContentSearchQuery::terms($query);
return array_map(static fn (array $row): array => [
'type' => self::publicType($row['type']),
'title' => $row['title'],
'url' => $row['url'],
'site' => $row['site'],
'breadcrumb' => $row['breadcrumb'],
'excerpt' => Excerpt::around($row['body_md'], $terms),
], $rows);
}
/**
* The tool's name for a stored type.
*/
public static function publicType(string $type): string
{
foreach (self::TYPES as $public => $types) {
if (in_array($type, $types, true)) {
return $public;
}
}
return $type;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Search;
/**
* Turns free text into a MySQL boolean-mode full-text query.
*
* InnoDB has no German stemming, so every term becomes a prefix match (`skipass*` finds
* Skipässe no better, but finds Skipassversicherung). Terms are optional rather than
* required: a question rarely uses the page's own words for everything, and the relevance
* score already ranks documents matching more terms higher. German stopwords are dropped
* here, since the server's stopword list is English.
*/
final class ContentSearchQuery
{
/** InnoDB's default innodb_ft_min_token_size; shorter tokens are not indexed. */
private const MIN_TOKEN_LENGTH = 3;
private const MAX_TERMS = 12;
private const STOPWORDS = [
'aber', 'alle', 'allem', 'allen', 'aller', 'alles', 'als', 'also', 'am', 'an', 'ander', 'andere', 'anderen',
'auch', 'auf', 'aus', 'bei', 'beim', 'bin', 'bis', 'bist', 'da', 'damit', 'dann', 'das', 'dass', 'dein',
'deine', 'dem', 'den', 'denn', 'der', 'des', 'dessen', 'die', 'dies', 'diese', 'diesem', 'diesen', 'dieser',
'dieses', 'doch', 'dort', 'du', 'durch', 'ein', 'eine', 'einem', 'einen', 'einer', 'eines', 'einige', 'er',
'es', 'etwas', 'euch', 'euer', 'eure', 'für', 'gibt', 'gib', 'hab', 'habe', 'haben', 'hast', 'hat', 'hatte',
'hier', 'hin', 'ich', 'ihr', 'ihre', 'ihrem', 'ihren', 'ihrer', 'im', 'in', 'ins', 'ist', 'ja', 'jede',
'jedem', 'jeden', 'jeder', 'jetzt', 'kann', 'kannst', 'kein', 'keine', 'können', 'könnt', 'man', 'mein',
'meine', 'mich', 'mir', 'mit', 'muss', 'musst', 'nach', 'nicht', 'nichts', 'noch', 'nun', 'nur', 'ob', 'oder',
'ohne', 'sehr', 'sein', 'seine', 'sich', 'sie', 'sind', 'so', 'soll', 'sollte', 'über', 'um', 'und', 'uns',
'unser', 'unsere', 'unter', 'viel', 'vom', 'von', 'vor', 'war', 'waren', 'was', 'weil', 'welche', 'welchem',
'welchen', 'welcher', 'welches', 'wenn', 'wer', 'werde', 'werden', 'wie', 'wieder', 'will', 'wir', 'wird',
'wo', 'woher', 'wohin', 'zu', 'zum', 'zur', 'zwischen', 'bitte', 'gerne', 'euren', 'eurem', 'eurer',
];
/**
* The terms of a query: lower-cased, stopwords and short tokens dropped, unique.
*
* @return list<string>
*/
public static function terms(string $text): array
{
$tokens = preg_split('/[^\p{L}\p{N}]+/u', mb_strtolower($text), -1, \PREG_SPLIT_NO_EMPTY) ?: [];
$terms = [];
foreach ($tokens as $token) {
if (mb_strlen($token) < self::MIN_TOKEN_LENGTH || in_array($token, self::STOPWORDS, true)) {
continue;
}
$terms[$token] = true;
}
return array_slice(array_keys($terms), 0, self::MAX_TERMS);
}
/**
* The boolean-mode query, or null when nothing searchable is left.
*/
public static function booleanQuery(string $text): ?string
{
$terms = self::terms($text);
return [] === $terms ? null : implode(' ', array_map(static fn (string $term): string => $term.'*', $terms));
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Search;
/**
* Cuts a window of about LENGTH characters out of a document, around its first match.
*
* The excerpt keeps search results small; get_document fetches the rest.
*/
final class Excerpt
{
public const LENGTH = 600;
/**
* @param list<string> $terms lower-cased, matched as prefixes like the full-text search
*/
public static function around(string $text, array $terms, int $length = self::LENGTH): string
{
$text = trim((string) preg_replace('/\s+/u', ' ', $text));
$textLength = mb_strlen($text);
if ($textLength <= $length) {
return $text;
}
$position = null;
$lower = mb_strtolower($text);
foreach ($terms as $term) {
$found = mb_strpos($lower, $term);
if (false !== $found && (null === $position || $found < $position)) {
$position = $found;
}
}
// Start a third of the window before the match, so it is read in context.
$start = max(0, min(($position ?? 0) - intdiv($length, 3), $textLength - $length));
$excerpt = mb_substr($text, $start, $length);
if ($start > 0) {
$space = mb_strpos($excerpt, ' ');
$excerpt = '…'.(false !== $space && $space < 40 ? mb_substr($excerpt, $space + 1) : $excerpt);
}
if ($start + $length < $textLength) {
$space = mb_strrpos($excerpt, ' ');
$excerpt = (false !== $space && mb_strlen($excerpt) - $space < 40 ? mb_substr($excerpt, 0, $space) : $excerpt).'…';
}
return $excerpt;
}
}
+653
View File
@@ -0,0 +1,653 @@
<?php
declare(strict_types=1);
namespace App\Chatbot;
use App\Chatbot\Search\ContentSearchQuery;
use App\Entity\ContentDocument;
use App\Model\Chatbot\TripCriteria;
use App\Model\Export;
use App\Repository\ContentDocumentRepository;
use App\Service\ProductExportBuilder;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
/**
* Trips as the chatbot sees them: TYPO3's editorial product joined to our dates, rooms,
* prices and pickups from the product export.
*
* The join is on the product code; several TYPO3 products may share one, and each is its
* own trip. Hotels are joined on any of the TYPO3 hotel's `codes`, because BusPro uses the
* matchcode aliases; a code carried by more than one hotel resolves to the one whose own
* `code` it is.
*
* A room is bookable unless its status is `Buchungsstop`. `available` counts rooms, so the
* places of a date x hotel are the sum of `available x maxPax` over its bookable rooms.
* Room counts never leave this class: the website does not show them, so the tools say
* only whether a date is freely bookable or on request.
*/
class TripCatalog
{
/** The feed's only language for now; trips are joined in it. */
public const LANGUAGE = 'de';
private const STATUS_BOOKING_STOP = 'Buchungsstop';
private const STATUS_FREE = 'Frei';
private const AVAILABILITY_FREE = 'frei';
private const AVAILABILITY_ON_REQUEST = 'auf Anfrage';
private const NEXT_DATES = 3;
private const DATES_WITH_PICKUPS = 3;
private const BODY_LIMIT = 20000;
private const HOTEL_BODY_LIMIT = 8000;
private const RECORD_TYPES = ['product', 'hotel', 'region', 'country', 'city', 'concept'];
public function __construct(
private readonly ProductExportBuilder $exportBuilder,
private readonly ContentDocumentRepository $repository,
private readonly ClockInterface $clock,
private readonly LoggerInterface $logger,
) {
}
/**
* @return array{results: list<array<string, mixed>>, dataAsOf: ?string}
*/
public function search(TripCriteria $criteria): array
{
$catalog = $this->load();
$products = $this->candidateProducts($catalog, $criteria);
$today = $this->today();
$results = [];
foreach ($products as $product) {
$data = $product->getData() ?? [];
$dates = [];
$hotelCodes = [];
foreach ($catalog['rowsByCode'][$this->normalize($data['code'] ?? null)] ?? [] as $row) {
$summary = $this->summarize($row);
if (!$this->qualifies($row, $summary, $criteria, $today)) {
continue;
}
$dateId = $row['date']['busProId'];
$hotelCodes[$this->normalize($row['hotel']['code'])] = true;
$current = $dates[$dateId] ?? null;
$dates[$dateId] = [
'dateFrom' => $row['date']['from'],
'dateTo' => $row['date']['to'],
'priceFrom' => null === $current ? $summary['priceFrom'] : $this->minPrice($current['priceFrom'], $summary['priceFrom']),
'availability' => self::AVAILABILITY_FREE === ($current['availability'] ?? null) ? self::AVAILABILITY_FREE : $summary['availability'],
];
}
if ([] === $dates) {
continue;
}
usort($dates, static fn (array $a, array $b): int => $a['dateFrom'] <=> $b['dateFrom']);
$results[] = $this->tripHeader($catalog, $product) + [
'hotels' => $this->hotelNames($catalog, array_keys($hotelCodes)),
'matchingDates' => count($dates),
'nextDates' => array_slice($dates, 0, self::NEXT_DATES),
];
}
usort($results, static fn (array $a, array $b): int => [$a['nextDates'][0]['dateFrom'], $a['name']] <=> [$b['nextDates'][0]['dateFrom'], $b['name']]);
return ['results' => array_slice($results, 0, $criteria->limit), 'dataAsOf' => $this->dataAsOf($catalog['export'])];
}
/**
* Everything about one trip, or null for a code no published product carries.
*
* @return array<string, mixed>|null
*/
public function get(string $code, ?string $hotelCode = null): ?array
{
$catalog = $this->load();
$products = $catalog['productsByCode'][$this->normalize($code)] ?? [];
if ([] === $products) {
return null;
}
usort($products, static fn (ContentDocument $a, ContentDocument $b): int => [null === $a->getUrl(), $a->getUid()] <=> [null === $b->getUrl(), $b->getUid()]);
$product = $products[0];
$today = $this->today();
$rows = array_values(array_filter(
$catalog['rowsByCode'][$this->normalize($code)] ?? [],
fn (array $row): bool => $row['date']['from'] >= $today,
));
if (null !== $hotelCode && '' !== trim($hotelCode)) {
$wanted = $this->hotelCodesFor($catalog, $hotelCode);
$rows = array_values(array_filter($rows, fn (array $row): bool => in_array($this->normalize($row['hotel']['code']), $wanted, true)));
}
usort($rows, static fn (array $a, array $b): int => [$a['date']['from'], $a['hotel']['code']] <=> [$b['date']['from'], $b['hotel']['code']]);
$hotelCodes = array_values(array_unique(array_map(fn (array $row): string => $this->normalize($row['hotel']['code']), $rows)));
return $this->tripHeader($catalog, $product) + [
'alsoPublishedAt' => array_values(array_map(
static fn (ContentDocument $other): array => ['site' => $other->getSite(), 'url' => $other->getUrl()],
array_filter(array_slice($products, 1), static fn (ContentDocument $other): bool => null !== $other->getUrl()),
)),
'description' => $this->truncate($product->getBodyMd(), self::BODY_LIMIT),
'hotels' => $this->hotelDetails($catalog, $hotelCodes, $product),
'dates' => $this->dates($catalog, $rows),
'dataAsOf' => $this->dataAsOf($catalog['export']),
];
}
/**
* Logs the codes the join cannot place, on both sides. The same gaps the website's
* product import tolerates, worth a periodic look.
*/
public function logUnmatchedCodes(): void
{
$catalog = $this->load();
$exportProducts = array_keys($catalog['rowsByCode']);
$contentProducts = array_keys($catalog['productsByCode']);
$exportHotels = [];
foreach ($catalog['export']->rows as $row) {
$exportHotels[$this->normalize($row['hotel']['code'] ?? null)] = true;
}
unset($exportHotels['']);
$ambiguous = [];
foreach ($catalog['hotelsByCode'] as $hotelCode => $hotels) {
if (count($hotels) > 1) {
$ambiguous[$hotelCode] = array_map(static fn (ContentDocument $hotel): int => $hotel->getUid(), $hotels);
}
}
$context = [
'exportProductsWithoutContent' => array_values(array_diff($exportProducts, $contentProducts)),
'contentProductsWithoutDates' => array_values(array_diff($contentProducts, $exportProducts)),
'exportHotelsWithoutContent' => array_values(array_diff(array_keys($exportHotels), array_keys($catalog['hotelsByCode']))),
'ambiguousHotelCodes' => $ambiguous,
];
$this->logger->info('Content feed: unmatched codes', $context + [
'counts' => array_map('count', $context),
]);
}
/**
* @return array{
* export: Export,
* records: array<string, ContentDocument>,
* productsByCode: array<string, list<ContentDocument>>,
* hotelsByCode: array<string, list<ContentDocument>>,
* rowsByCode: array<string, list<array<string, mixed>>>,
* }
*/
private function load(): array
{
$export = $this->exportBuilder->build();
$records = $this->repository->findRecords(self::RECORD_TYPES, self::LANGUAGE);
$productsByCode = [];
$hotelsByCode = [];
foreach ($records as $record) {
$data = $record->getData() ?? [];
if ('product' === $record->getType() && '' !== ($code = $this->normalize($data['code'] ?? null))) {
$productsByCode[$code][] = $record;
}
if ('hotel' === $record->getType()) {
$codes = is_array($data['codes'] ?? null) ? $data['codes'] : [];
$codes[] = $data['code'] ?? null;
foreach (array_unique(array_map($this->normalize(...), $codes)) as $code) {
if ('' !== $code) {
$hotelsByCode[$code][] = $record;
}
}
}
}
// A code that is one hotel's own and another's alias resolves to the owner.
foreach ($hotelsByCode as $code => $hotels) {
usort($hotels, fn (ContentDocument $a, ContentDocument $b): int => [$this->normalize($a->getData()['code'] ?? null) !== $code, $a->getUid()] <=> [$this->normalize($b->getData()['code'] ?? null) !== $code, $b->getUid()]);
$hotelsByCode[$code] = $hotels;
}
$rowsByCode = [];
foreach ($export->rows as $row) {
$code = $this->normalize($row['product']['code'] ?? null);
if ('' !== $code) {
$rowsByCode[$code][] = $row;
}
}
return [
'export' => $export,
'records' => $records,
'productsByCode' => $productsByCode,
'hotelsByCode' => $hotelsByCode,
'rowsByCode' => $rowsByCode,
];
}
/**
* Published products narrowed by everything the content index can resolve: site, day
* trip, concept, destination and free text.
*
* @param array<string, mixed> $catalog
*
* @return list<ContentDocument>
*/
private function candidateProducts(array $catalog, TripCriteria $criteria): array
{
/** @var array<string, ContentDocument> $records */
$records = $catalog['records'];
$conceptUids = null === $criteria->concept ? null : $this->matchingUids($records, ['concept'], $criteria->concept, matchCode: true);
$countryUids = null === $criteria->country ? null : $this->matchingUids($records, ['country'], $criteria->country, matchCode: true);
$regionUids = null === $criteria->region ? null : $this->matchingUids($records, ['region'], $criteria->region);
$cityUids = null === $criteria->region ? null : $this->matchingUids($records, ['city'], $criteria->region);
$queryUids = null === $criteria->query ? null : $this->productUidsMatchingText($records, $criteria->query);
$products = [];
foreach ($records as $record) {
if ('product' !== $record->getType() || null === $record->getUrl()) {
continue;
}
$data = $record->getData() ?? [];
// Closed group trips and staff trainings are not offered; get() still answers
// someone who names their code.
if (false === ($data['bookable'] ?? null)
|| $record->isHiddenFromSearch()
|| (null !== $criteria->site && $record->getSite() !== $criteria->site)
|| (null !== $criteria->daytrip && (bool) ($data['daytrip'] ?? false) !== $criteria->daytrip)
|| (null !== $conceptUids && !in_array($data['conceptUid'] ?? null, $conceptUids, true))
|| (null !== $countryUids && !in_array($data['countryUid'] ?? null, $countryUids, true))
|| (null !== $queryUids && !in_array($record->getUid(), $queryUids, true))) {
continue;
}
if (null !== $regionUids) {
$productRegions = array_merge([$data['regionUid'] ?? null], is_array($data['additionalRegionUids'] ?? null) ? $data['additionalRegionUids'] : []);
if ([] === array_intersect($productRegions, $regionUids) && !in_array($data['cityUid'] ?? null, $cityUids ?? [], true)) {
continue;
}
}
$products[] = $record;
}
return $products;
}
/**
* Uids of the records of the given types whose name contains the text, or whose code
* equals it.
*
* @param array<string, ContentDocument> $records
* @param list<string> $types
*
* @return list<int>
*/
private function matchingUids(array $records, array $types, string $text, bool $matchCode = false): array
{
$needle = mb_strtolower(trim($text));
$uids = [];
foreach ($records as $record) {
if (!in_array($record->getType(), $types, true)) {
continue;
}
$name = mb_strtolower($record->getTitle());
$code = $this->normalize($record->getData()['code'] ?? null);
if (('' !== $name && (str_contains($name, $needle) || str_contains($needle, $name)))
|| ($matchCode && '' !== $code && $code === $this->normalize($text))) {
$uids[] = $record->getUid();
}
}
return $uids;
}
/**
* Products reached by a full-text match on themselves or on a hotel, destination or
* concept they belong to.
*
* @param array<string, ContentDocument> $records
*
* @return list<int>
*/
private function productUidsMatchingText(array $records, string $text): array
{
$booleanQuery = ContentSearchQuery::booleanQuery($text);
if (null === $booleanQuery) {
return array_values(array_map(static fn (ContentDocument $record): int => $record->getUid(), array_filter($records, static fn (ContentDocument $record): bool => 'product' === $record->getType())));
}
$matched = [];
foreach ($this->repository->matchRecords($booleanQuery, self::RECORD_TYPES, self::LANGUAGE) as $hit) {
$matched[$hit['type']][$hit['uid']] = true;
}
$uids = [];
foreach ($records as $record) {
if ('product' !== $record->getType()) {
continue;
}
$data = $record->getData() ?? [];
$hotelUids = is_array($data['hotelUids'] ?? null) ? $data['hotelUids'] : [];
$regionUids = array_merge([$data['regionUid'] ?? null], is_array($data['additionalRegionUids'] ?? null) ? $data['additionalRegionUids'] : []);
$hit = isset($matched['product'][$record->getUid()])
|| [] !== array_intersect($hotelUids, array_keys($matched['hotel'] ?? []))
|| [] !== array_intersect($regionUids, array_keys($matched['region'] ?? []))
|| isset($matched['country'][$data['countryUid'] ?? -1])
|| isset($matched['city'][$data['cityUid'] ?? -1])
|| isset($matched['concept'][$data['conceptUid'] ?? -1]);
// Hotels also name their products, for products whose hotelUids miss them.
if (!$hit) {
foreach (array_keys($matched['hotel'] ?? []) as $hotelUid) {
$hotelProducts = ($records['hotel:'.$hotelUid] ?? null)?->getData()['productUids'] ?? [];
if (is_array($hotelProducts) && in_array($record->getUid(), $hotelProducts, true)) {
$hit = true;
break;
}
}
}
if ($hit) {
$uids[] = $record->getUid();
}
}
return $uids;
}
/**
* @param array<string, mixed> $row
*
* @return array{priceFrom: ?float, places: int, availability: ?string}
*/
private function summarize(array $row): array
{
$priceFrom = null;
$places = 0;
$availability = null;
foreach ($row['rooms'] ?? [] as $room) {
if (self::STATUS_BOOKING_STOP === ($room['status'] ?? null)) {
continue;
}
$places += (int) ($room['available'] ?? 0) * (int) ($room['maxPax'] ?? 0);
$availability = self::STATUS_FREE === ($room['status'] ?? null) || self::AVAILABILITY_FREE === $availability
? self::AVAILABILITY_FREE
: self::AVAILABILITY_ON_REQUEST;
if (is_int($room['price'] ?? null) || is_float($room['price'] ?? null)) {
$priceFrom = $this->minPrice($priceFrom, (float) $room['price']);
}
}
return ['priceFrom' => $priceFrom, 'places' => $places, 'availability' => $availability];
}
/**
* @param array<string, mixed> $row
* @param array{priceFrom: ?float, places: int, availability: ?string} $summary
*/
private function qualifies(array $row, array $summary, TripCriteria $criteria, string $today): bool
{
$from = $row['date']['from'];
$to = $row['date']['to'];
return $from >= $today
&& null !== $summary['availability']
&& (null === $criteria->dateFrom || $to >= $criteria->dateFrom->format('Y-m-d'))
&& (null === $criteria->dateTo || $from <= $criteria->dateTo->format('Y-m-d'))
&& (null === $criteria->maxPrice || (null !== $summary['priceFrom'] && $summary['priceFrom'] <= $criteria->maxPrice))
&& (null === $criteria->persons || $summary['places'] >= $criteria->persons);
}
/**
* @param array<string, mixed> $catalog
*
* @return array<string, mixed>
*/
private function tripHeader(array $catalog, ContentDocument $product): array
{
$data = $product->getData() ?? [];
$name = fn (string $type, mixed $uid): ?string => is_int($uid) ? ($catalog['records'][$type.':'.$uid] ?? null)?->getTitle() : null;
return [
'code' => $data['code'] ?? null,
'name' => $product->getTitle(),
'url' => $product->getUrl(),
'site' => $product->getSite(),
'destination' => array_filter([
'country' => $name('country', $data['countryUid'] ?? null),
'region' => $name('region', $data['regionUid'] ?? null),
'city' => $name('city', $data['cityUid'] ?? null),
]),
'concept' => $name('concept', $data['conceptUid'] ?? null),
'daytrip' => (bool) ($data['daytrip'] ?? false),
];
}
/**
* @param array<string, mixed> $catalog
* @param list<string> $hotelCodes normalized
*
* @return list<string>
*/
private function hotelNames(array $catalog, array $hotelCodes): array
{
$names = [];
foreach ($hotelCodes as $code) {
$hotel = $catalog['hotelsByCode'][$code][0] ?? null;
if (null !== $hotel && '' !== $hotel->getTitle()) {
$names[$hotel->getTitle()] = true;
}
}
return array_keys($names);
}
/**
* @param array<string, mixed> $catalog
* @param list<string> $hotelCodes normalized codes of the hotels with dates
*
* @return list<array<string, mixed>>
*/
private function hotelDetails(array $catalog, array $hotelCodes, ContentDocument $product): array
{
$hotels = [];
foreach ($hotelCodes as $code) {
$hotel = $catalog['hotelsByCode'][$code][0] ?? null;
if (null !== $hotel) {
$hotels[$hotel->getUid()] = $hotel;
}
}
// A trip without upcoming dates still describes its hotels.
if ([] === $hotels) {
foreach ((array) ($product->getData()['hotelUids'] ?? []) as $uid) {
$hotel = is_int($uid) ? ($catalog['records']['hotel:'.$uid] ?? null) : null;
if (null !== $hotel) {
$hotels[$hotel->getUid()] = $hotel;
}
}
}
return array_values(array_map(fn (ContentDocument $hotel): array => [
'code' => $hotel->getData()['code'] ?? null,
'name' => $hotel->getTitle(),
'url' => $hotel->getUrl(),
'facts' => is_array($hotel->getData()['facts'] ?? null) ? array_filter($hotel->getData()['facts'], static fn (mixed $value): bool => null !== $value && '' !== $value) : [],
'description' => $this->truncate($hotel->getBodyMd(), self::HOTEL_BODY_LIMIT),
], $hotels));
}
/**
* Upcoming dates with their bookable rooms per hotel. Pickups for the first few only:
* they rarely differ between dates and would otherwise dominate the answer.
*
* @param array<string, mixed> $catalog
* @param list<array<string, mixed>> $rows sorted by date
*
* @return list<array<string, mixed>>
*/
private function dates(array $catalog, array $rows): array
{
$dates = [];
foreach ($rows as $row) {
$dateId = $row['date']['busProId'];
$hotel = $catalog['hotelsByCode'][$this->normalize($row['hotel']['code'])][0] ?? null;
$rooms = [];
foreach ($row['rooms'] ?? [] as $room) {
if (self::STATUS_BOOKING_STOP === ($room['status'] ?? null)) {
continue;
}
$rooms[] = array_filter([
'room' => $room['label'] ?? null,
'board' => $room['boardCode'] ?? null,
'nights' => $room['nights'] ?? null,
'price' => $room['price'] ?? null,
'availability' => self::STATUS_FREE === ($room['status'] ?? null) ? self::AVAILABILITY_FREE : self::AVAILABILITY_ON_REQUEST,
], static fn (mixed $value): bool => null !== $value);
}
$dates[$dateId] ??= [
'dateFrom' => $row['date']['from'],
'dateTo' => $row['date']['to'],
'hotels' => [],
'pickups' => $this->pickups($row),
];
$dates[$dateId]['hotels'][] = [
'hotel' => $hotel?->getTitle() ?? $row['hotel']['code'],
'hotelCode' => $row['hotel']['code'],
'rooms' => $rooms,
'bookable' => [] !== $rooms,
];
}
$dates = array_values($dates);
foreach ($dates as $i => $date) {
if ($i >= self::DATES_WITH_PICKUPS) {
unset($dates[$i]['pickups']);
}
}
return $dates;
}
/**
* @param array<string, mixed> $row
*
* @return list<array<string, mixed>>
*/
private function pickups(array $row): array
{
$pickups = [];
foreach ($row['pickups'] ?? [] as $pickup) {
if (0 === ($pickup['busProId'] ?? 0) || null === ($pickup['city'] ?? null)) {
continue;
}
$pickups[] = array_filter([
'city' => $pickup['city'],
'place' => $pickup['street'] ?? null,
// A clock reading without an offset, as BusPro states it.
'departure' => is_string($pickup['datetime'] ?? null) ? str_replace('T', ' ', substr($pickup['datetime'], 0, 16)) : null,
'surcharge' => $pickup['price'] ?? null,
], static fn (mixed $value): bool => null !== $value);
}
return $pickups;
}
/**
* Normalized codes of the hotel a code names: all its own codes when TYPO3 knows the
* hotel, else just the code.
*
* @param array<string, mixed> $catalog
*
* @return list<string>
*/
private function hotelCodesFor(array $catalog, string $hotelCode): array
{
$hotel = $catalog['hotelsByCode'][$this->normalize($hotelCode)][0] ?? null;
if (null === $hotel) {
return [$this->normalize($hotelCode)];
}
$codes = is_array($hotel->getData()['codes'] ?? null) ? $hotel->getData()['codes'] : [];
$codes[] = $hotel->getData()['code'] ?? null;
return array_values(array_filter(array_unique(array_map($this->normalize(...), $codes))));
}
private function dataAsOf(Export $export): string
{
return ($export->sourceGeneratedAt ?? $export->generatedAt)->format('c');
}
private function truncate(string $text, int $limit): string
{
if (mb_strlen($text) <= $limit) {
return $text;
}
return mb_substr($text, 0, $limit)."\n\n[… gekürzt, vollständiger Text unter der URL]";
}
private function minPrice(?float $a, ?float $b): ?float
{
if (null === $a || null === $b) {
return $a ?? $b;
}
return min($a, $b);
}
private function today(): string
{
return $this->clock->now()->setTimezone(new \DateTimeZone('Europe/Berlin'))->format('Y-m-d');
}
private function normalize(mixed $code): string
{
return is_string($code) ? strtoupper(trim($code)) : '';
}
}