feat: add MCP server for the website chatbot over a mirrored TYPO3 content feed
This commit is contained in:
1 parent
ff7ddf7a9e
commit
1b4384e3f3
52 files changed
+5117
-4
No files matched your search
@@ -82,8 +82,11 @@ APP_LEGAL_NOTICE_URL="https://www.ep-reisen.de/unternehmen/impressum-skireisever
|
|||||||
APP_TERMS_AND_CONDITIONS_URL="https://www.ep-reisen.de/skireisen/infos/allgemeine-infos/agb/"
|
APP_TERMS_AND_CONDITIONS_URL="https://www.ep-reisen.de/skireisen/infos/allgemeine-infos/agb/"
|
||||||
APP_PRIVACY_POLICY_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/datenschutz-winterreisen/"
|
APP_PRIVACY_POLICY_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/datenschutz-winterreisen/"
|
||||||
APP_CMS_API_BASE_URL="https://www.ep-reisen.de/"
|
APP_CMS_API_BASE_URL="https://www.ep-reisen.de/"
|
||||||
# TYPO3 API key (X-Api-Key), sent by the typo3.client scoped client; the real value per stage goes in .env.local
|
# TYPO3 API key (X-Api-Key), sent by the typo3.client scoped client and by app:content:sync;
|
||||||
|
# the real value per stage goes in .env.local
|
||||||
APP_CMS_API_KEY=
|
APP_CMS_API_KEY=
|
||||||
|
# Static bearer token n8n sends to the chatbot MCP endpoint (/mcp); empty refuses every request
|
||||||
|
APP_MCP_TOKEN=
|
||||||
APP_WEBSITE_BASE_URL="https://www.ep-reisen.de/"
|
APP_WEBSITE_BASE_URL="https://www.ep-reisen.de/"
|
||||||
APP_BASE_URL="https://my.ep-reisen.de"
|
APP_BASE_URL="https://my.ep-reisen.de"
|
||||||
APP_CUSTOMER_SERVICE_EMAIL="[email protected]"
|
APP_CUSTOMER_SERVICE_EMAIL="[email protected]"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"knplabs/knp-paginator-bundle": "^6.10",
|
"knplabs/knp-paginator-bundle": "^6.10",
|
||||||
"league/flysystem-bundle": "^3.4",
|
"league/flysystem-bundle": "^3.4",
|
||||||
"league/flysystem-sftp-v3": "^3.29",
|
"league/flysystem-sftp-v3": "^3.29",
|
||||||
|
"league/html-to-markdown": "^5.1",
|
||||||
"league/oauth2-server-bundle": "^1.0",
|
"league/oauth2-server-bundle": "^1.0",
|
||||||
"maennchen/zipstream-php": "^3.2",
|
"maennchen/zipstream-php": "^3.2",
|
||||||
"nesbot/carbon": "^3.8",
|
"nesbot/carbon": "^3.8",
|
||||||
@@ -43,12 +44,14 @@
|
|||||||
"symfony/intl": "7.4.*",
|
"symfony/intl": "7.4.*",
|
||||||
"symfony/lock": "7.4.*",
|
"symfony/lock": "7.4.*",
|
||||||
"symfony/mailer": "7.4.*",
|
"symfony/mailer": "7.4.*",
|
||||||
|
"symfony/mcp-bundle": "^0.14.1",
|
||||||
"symfony/mime": "7.4.*",
|
"symfony/mime": "7.4.*",
|
||||||
"symfony/monolog-bundle": "^3.0",
|
"symfony/monolog-bundle": "^3.0",
|
||||||
"symfony/notifier": "7.4.*",
|
"symfony/notifier": "7.4.*",
|
||||||
"symfony/process": "7.4.*",
|
"symfony/process": "7.4.*",
|
||||||
"symfony/property-access": "7.4.*",
|
"symfony/property-access": "7.4.*",
|
||||||
"symfony/property-info": "7.4.*",
|
"symfony/property-info": "7.4.*",
|
||||||
|
"symfony/rate-limiter": "7.4.*",
|
||||||
"symfony/runtime": "7.4.*",
|
"symfony/runtime": "7.4.*",
|
||||||
"symfony/security-bundle": "7.4.*",
|
"symfony/security-bundle": "7.4.*",
|
||||||
"symfony/serializer": "7.4.*",
|
"symfony/serializer": "7.4.*",
|
||||||
|
|||||||
Generated
+657
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "2c2a8c19ca2e117f521584a4e3e6212c",
|
"content-hash": "c21fb67f385dd7290a440aec1a70cb9f",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "carbonphp/carbon-doctrine-types",
|
"name": "carbonphp/carbon-doctrine-types",
|
||||||
@@ -2521,6 +2521,95 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-09-02T07:49:56+00:00"
|
"time": "2026-09-02T07:49:56+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "league/html-to-markdown",
|
||||||
|
"version": "5.1.3",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/thephpleague/html-to-markdown.git",
|
||||||
|
"reference": "5411b9650dd468be52267a6f4a2a26c31d2581be"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/5411b9650dd468be52267a6f4a2a26c31d2581be",
|
||||||
|
"reference": "5411b9650dd468be52267a6f4a2a26c31d2581be",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-xml": "*",
|
||||||
|
"php": "^7.2.5 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mikehaertl/php-shellcommand": "^1.1.0",
|
||||||
|
"phpstan/phpstan": "^1.8.8 || ^2.0",
|
||||||
|
"phpunit/phpunit": "^8.5 || ^9.2 || ^10.5 || ^11.0 || ^12.0 || ^13.0",
|
||||||
|
"scrutinizer/ocular": "^1.6",
|
||||||
|
"unleashedtech/php-coding-standard": "^2.7 || ^3.0",
|
||||||
|
"vimeo/psalm": "^4.22 || ^5.0 || ^6.0"
|
||||||
|
},
|
||||||
|
"bin": [
|
||||||
|
"bin/html-to-markdown"
|
||||||
|
],
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "5.2-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"League\\HTMLToMarkdown\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Colin O'Dell",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"homepage": "https://www.colinodell.com",
|
||||||
|
"role": "Lead Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Nick Cernis",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"homepage": "http://modernnerd.net",
|
||||||
|
"role": "Original Author"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "An HTML-to-markdown conversion helper for PHP",
|
||||||
|
"homepage": "https://github.com/thephpleague/html-to-markdown",
|
||||||
|
"keywords": [
|
||||||
|
"html",
|
||||||
|
"markdown"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/thephpleague/html-to-markdown/issues",
|
||||||
|
"source": "https://github.com/thephpleague/html-to-markdown/tree/5.1.3"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://www.colinodell.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://www.paypal.me/colinpodell/10.00",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/colinodell",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/league/html-to-markdown",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-10-06T01:08:41+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "league/mime-type-detection",
|
"name": "league/mime-type-detection",
|
||||||
"version": "1.17.0",
|
"version": "1.17.0",
|
||||||
@@ -3192,6 +3281,91 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-08-18T06:18:41+00:00"
|
"time": "2026-08-18T06:18:41+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "mcp/sdk",
|
||||||
|
"version": "v0.8.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/modelcontextprotocol/php-sdk.git",
|
||||||
|
"reference": "c5dbfb64e5a2a30872ec3927b4cf13a2eb05e10e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/c5dbfb64e5a2a30872ec3927b4cf13a2eb05e10e",
|
||||||
|
"reference": "c5dbfb64e5a2a30872ec3927b4cf13a2eb05e10e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-fileinfo": "*",
|
||||||
|
"opis/json-schema": "^2.4",
|
||||||
|
"php": "^8.1",
|
||||||
|
"php-http/discovery": "^1.20",
|
||||||
|
"phpdocumentor/reflection-docblock": "^5.6 || ^6.0",
|
||||||
|
"psr/clock": "^1.0",
|
||||||
|
"psr/container": "^1.0 || ^2.0",
|
||||||
|
"psr/event-dispatcher": "^1.0",
|
||||||
|
"psr/http-client": "^1.0",
|
||||||
|
"psr/http-factory": "^1.1",
|
||||||
|
"psr/http-message": "^1.1 || ^2.0",
|
||||||
|
"psr/http-server-handler": "^1.0",
|
||||||
|
"psr/http-server-middleware": "^1.0",
|
||||||
|
"psr/log": "^1.0 || ^2.0 || ^3.0",
|
||||||
|
"symfony/deprecation-contracts": "^2.5 || ^3.0",
|
||||||
|
"symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"composer/semver": "^3.0",
|
||||||
|
"ext-openssl": "*",
|
||||||
|
"firebase/php-jwt": "^6.10 || ^7.0",
|
||||||
|
"laminas/laminas-httphandlerrunner": "^2.12",
|
||||||
|
"nyholm/psr7": "^1.8",
|
||||||
|
"nyholm/psr7-server": "^1.1",
|
||||||
|
"phar-io/composer-distributor": "^1.0.2",
|
||||||
|
"php-cs-fixer/shim": "^3.91",
|
||||||
|
"phpdocumentor/shim": "^3",
|
||||||
|
"phpstan/phpstan": "^2.1",
|
||||||
|
"phpunit/phpunit": "^10.5",
|
||||||
|
"psr/simple-cache": "^2.0 || ^3.0",
|
||||||
|
"symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0",
|
||||||
|
"symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0",
|
||||||
|
"symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0",
|
||||||
|
"symfony/http-client": "^5.4 || ^6.4 || ^7.3 || ^8.0",
|
||||||
|
"symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"symfony/finder": "Required for file-based discovery."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Mcp\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"Apache-2.0"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Christopher Hertel",
|
||||||
|
"email": "[email protected]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kyrian Obikwelu",
|
||||||
|
"email": "[email protected]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tobias Nyholm",
|
||||||
|
"email": "[email protected]"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Model Context Protocol SDK for Client and Server applications in PHP",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/modelcontextprotocol/php-sdk/issues",
|
||||||
|
"source": "https://github.com/modelcontextprotocol/php-sdk/tree/v0.8.1"
|
||||||
|
},
|
||||||
|
"time": "2026-08-29T23:10:56+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "monolog/monolog",
|
"name": "monolog/monolog",
|
||||||
"version": "3.12.1",
|
"version": "3.12.1",
|
||||||
@@ -3480,6 +3654,196 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-09-09T07:06:30+00:00"
|
"time": "2024-09-09T07:06:30+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "opis/json-schema",
|
||||||
|
"version": "2.6.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/opis/json-schema.git",
|
||||||
|
"reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/opis/json-schema/zipball/8458763e0dd0b6baa310e04f1829fc73da4e8c8a",
|
||||||
|
"reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"opis/string": "^2.1",
|
||||||
|
"opis/uri": "^1.0",
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"ext-bcmath": "*",
|
||||||
|
"ext-intl": "*",
|
||||||
|
"phpunit/phpunit": "^9.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "2.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Opis\\JsonSchema\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"Apache-2.0"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Sorin Sarca",
|
||||||
|
"email": "[email protected]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Marius Sarca",
|
||||||
|
"email": "[email protected]"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Json Schema Validator for PHP",
|
||||||
|
"homepage": "https://opis.io/json-schema",
|
||||||
|
"keywords": [
|
||||||
|
"json",
|
||||||
|
"json-schema",
|
||||||
|
"schema",
|
||||||
|
"validation",
|
||||||
|
"validator"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/opis/json-schema/issues",
|
||||||
|
"source": "https://github.com/opis/json-schema/tree/2.6.0"
|
||||||
|
},
|
||||||
|
"time": "2025-10-17T12:46:48+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "opis/string",
|
||||||
|
"version": "2.1.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/opis/string.git",
|
||||||
|
"reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/opis/string/zipball/3e4d2aaff518ac518530b89bb26ed40f4503635e",
|
||||||
|
"reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"ext-json": "*",
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^9.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "2.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Opis\\String\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"Apache-2.0"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Marius Sarca",
|
||||||
|
"email": "[email protected]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sorin Sarca",
|
||||||
|
"email": "[email protected]"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Multibyte strings as objects",
|
||||||
|
"homepage": "https://opis.io/string",
|
||||||
|
"keywords": [
|
||||||
|
"multi-byte",
|
||||||
|
"opis",
|
||||||
|
"string",
|
||||||
|
"string manipulation",
|
||||||
|
"utf-8"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/opis/string/issues",
|
||||||
|
"source": "https://github.com/opis/string/tree/2.1.0"
|
||||||
|
},
|
||||||
|
"time": "2025-10-17T12:38:41+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "opis/uri",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/opis/uri.git",
|
||||||
|
"reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a",
|
||||||
|
"reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"opis/string": "^2.0",
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^9"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Opis\\Uri\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"Apache-2.0"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Marius Sarca",
|
||||||
|
"email": "[email protected]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sorin Sarca",
|
||||||
|
"email": "[email protected]"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Build, parse and validate URIs and URI-templates",
|
||||||
|
"homepage": "https://opis.io",
|
||||||
|
"keywords": [
|
||||||
|
"URI Template",
|
||||||
|
"parse url",
|
||||||
|
"punycode",
|
||||||
|
"uri",
|
||||||
|
"uri components",
|
||||||
|
"url",
|
||||||
|
"validate uri"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/opis/uri/issues",
|
||||||
|
"source": "https://github.com/opis/uri/tree/1.1.0"
|
||||||
|
},
|
||||||
|
"time": "2021-05-22T15:57:08+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "paragonie/constant_time_encoding",
|
"name": "paragonie/constant_time_encoding",
|
||||||
"version": "v3.1.3",
|
"version": "v3.1.3",
|
||||||
@@ -3599,6 +3963,85 @@
|
|||||||
},
|
},
|
||||||
"time": "2020-10-15T08:29:30+00:00"
|
"time": "2020-10-15T08:29:30+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "php-http/discovery",
|
||||||
|
"version": "1.20.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-http/discovery.git",
|
||||||
|
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d",
|
||||||
|
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer-plugin-api": "^1.0|^2.0",
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"nyholm/psr7": "<1.0",
|
||||||
|
"zendframework/zend-diactoros": "*"
|
||||||
|
},
|
||||||
|
"provide": {
|
||||||
|
"php-http/async-client-implementation": "*",
|
||||||
|
"php-http/client-implementation": "*",
|
||||||
|
"psr/http-client-implementation": "*",
|
||||||
|
"psr/http-factory-implementation": "*",
|
||||||
|
"psr/http-message-implementation": "*"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"composer/composer": "^1.0.2|^2.0",
|
||||||
|
"graham-campbell/phpspec-skip-example-extension": "^5.0",
|
||||||
|
"php-http/httplug": "^1.0 || ^2.0",
|
||||||
|
"php-http/message-factory": "^1.0",
|
||||||
|
"phpspec/phpspec": "^5.1 || ^6.1 || ^7.3",
|
||||||
|
"sebastian/comparator": "^3.0.5 || ^4.0.8",
|
||||||
|
"symfony/phpunit-bridge": "^6.4.4 || ^7.0.1"
|
||||||
|
},
|
||||||
|
"type": "composer-plugin",
|
||||||
|
"extra": {
|
||||||
|
"class": "Http\\Discovery\\Composer\\Plugin",
|
||||||
|
"plugin-optional": true
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Http\\Discovery\\": "src/"
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"src/Composer/Plugin.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Márk Sági-Kazár",
|
||||||
|
"email": "[email protected]"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations",
|
||||||
|
"homepage": "http://php-http.org",
|
||||||
|
"keywords": [
|
||||||
|
"adapter",
|
||||||
|
"client",
|
||||||
|
"discovery",
|
||||||
|
"factory",
|
||||||
|
"http",
|
||||||
|
"message",
|
||||||
|
"psr17",
|
||||||
|
"psr7"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/php-http/discovery/issues",
|
||||||
|
"source": "https://github.com/php-http/discovery/tree/1.20.0"
|
||||||
|
},
|
||||||
|
"time": "2024-10-02T11:20:13+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "phpdocumentor/reflection-common",
|
"name": "phpdocumentor/reflection-common",
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
@@ -4247,6 +4690,58 @@
|
|||||||
},
|
},
|
||||||
"time": "2019-01-08T18:20:26+00:00"
|
"time": "2019-01-08T18:20:26+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "psr/http-client",
|
||||||
|
"version": "1.0.3",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-fig/http-client.git",
|
||||||
|
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
|
||||||
|
"reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.0 || ^8.0",
|
||||||
|
"psr/http-message": "^1.0 || ^2.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.0.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Psr\\Http\\Client\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "PHP-FIG",
|
||||||
|
"homepage": "https://www.php-fig.org/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Common interface for HTTP clients",
|
||||||
|
"homepage": "https://github.com/php-fig/http-client",
|
||||||
|
"keywords": [
|
||||||
|
"http",
|
||||||
|
"http-client",
|
||||||
|
"psr",
|
||||||
|
"psr-18"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/php-fig/http-client"
|
||||||
|
},
|
||||||
|
"time": "2023-09-23T14:17:50+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "psr/http-factory",
|
"name": "psr/http-factory",
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
@@ -7296,6 +7791,93 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-09-15T06:01:15+00:00"
|
"time": "2026-09-15T06:01:15+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/mcp-bundle",
|
||||||
|
"version": "v0.14.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/mcp-bundle.git",
|
||||||
|
"reference": "f9f5be5e16a7b4749ca98d8099042902fe917a74"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/mcp-bundle/zipball/f9f5be5e16a7b4749ca98d8099042902fe917a74",
|
||||||
|
"reference": "f9f5be5e16a7b4749ca98d8099042902fe917a74",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"mcp/sdk": "^0.8.1",
|
||||||
|
"php-http/discovery": "^1.20",
|
||||||
|
"symfony/config": "^7.3|^8.0",
|
||||||
|
"symfony/console": "^7.3|^8.0",
|
||||||
|
"symfony/dependency-injection": "^7.3|^8.0",
|
||||||
|
"symfony/finder": "^7.3|^8.0",
|
||||||
|
"symfony/framework-bundle": "^7.3|^8.0",
|
||||||
|
"symfony/http-foundation": "^7.3|^8.0",
|
||||||
|
"symfony/http-kernel": "^7.3|^8.0",
|
||||||
|
"symfony/psr-http-message-bridge": "^7.3|^8.0",
|
||||||
|
"symfony/routing": "^7.3|^8.0",
|
||||||
|
"symfony/service-contracts": "^2.5|^3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"nyholm/psr7": "^1.8",
|
||||||
|
"phpstan/phpstan": "^2.1",
|
||||||
|
"phpstan/phpstan-phpunit": "^2.0",
|
||||||
|
"phpstan/phpstan-strict-rules": "^2.0",
|
||||||
|
"phpunit/phpunit": "^11.5.53",
|
||||||
|
"symfony/monolog-bundle": "^3.10 || ^4.0",
|
||||||
|
"symfony/twig-bundle": "^7.3|^8.0"
|
||||||
|
},
|
||||||
|
"type": "symfony-bundle",
|
||||||
|
"extra": {
|
||||||
|
"thanks": {
|
||||||
|
"url": "https://github.com/symfony/ai",
|
||||||
|
"name": "symfony/ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\AI\\McpBundle\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Christopher Hertel",
|
||||||
|
"email": "[email protected]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony integration bundle for Model Context Protocol (via official mcp/sdk)",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/mcp-bundle/tree/v0.14.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-09-26T01:53:23+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/messenger",
|
"name": "symfony/messenger",
|
||||||
"version": "v7.4.20",
|
"version": "v7.4.20",
|
||||||
@@ -9028,6 +9610,80 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-24T13:12:05+00:00"
|
"time": "2026-03-24T13:12:05+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/rate-limiter",
|
||||||
|
"version": "v7.4.18",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/rate-limiter.git",
|
||||||
|
"reference": "6703d040ab83401b27f3c7b29ff4c8c8106fedba"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/rate-limiter/zipball/6703d040ab83401b27f3c7b29ff4c8c8106fedba",
|
||||||
|
"reference": "6703d040ab83401b27f3c7b29ff4c8c8106fedba",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2",
|
||||||
|
"symfony/options-resolver": "^7.3|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"psr/cache": "^1.0|^2.0|^3.0",
|
||||||
|
"symfony/lock": "^6.4|^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\RateLimiter\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Wouter de Jong",
|
||||||
|
"email": "[email protected]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides a Token Bucket implementation to rate limit input and output in your application",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"limiter",
|
||||||
|
"rate-limiter"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/rate-limiter/tree/v7.4.18"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-08-07T15:34:54+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/routing",
|
"name": "symfony/routing",
|
||||||
"version": "v7.4.20",
|
"version": "v7.4.20",
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ return [
|
|||||||
Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true],
|
Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true],
|
||||||
Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
|
Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
|
||||||
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
|
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
|
||||||
|
Symfony\AI\McpBundle\McpBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
@@ -16,6 +16,13 @@ framework:
|
|||||||
php_errors:
|
php_errors:
|
||||||
log: true
|
log: true
|
||||||
|
|
||||||
|
rate_limiter:
|
||||||
|
# Per MCP token. A chatbot turn makes a few tool calls; this catches runaway loops.
|
||||||
|
chatbot_mcp:
|
||||||
|
policy: sliding_window
|
||||||
|
limit: 60
|
||||||
|
interval: '1 minute'
|
||||||
|
|
||||||
http_client:
|
http_client:
|
||||||
scoped_clients:
|
scoped_clients:
|
||||||
typo3.client:
|
typo3.client:
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
|
||||||
|
http_discovery.psr17_factory:
|
||||||
|
class: Http\Discovery\Psr17Factory
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# The website chatbot's MCP server, called by the n8n AI Agent through its MCP Client Tool
|
||||||
|
# node. Read-only tools over the mirrored TYPO3 content and our own trip data; see
|
||||||
|
# docs/chatbot-mcp.md. Authentication is the "mcp" firewall in security.yaml.
|
||||||
|
mcp:
|
||||||
|
servers:
|
||||||
|
chatbot:
|
||||||
|
name: 'MyE&P chatbot'
|
||||||
|
version: '1.0.0'
|
||||||
|
instructions: 'Answers questions about the trips and website content of E&P Reisen and its brands. Every result carries a url to cite. Prices and availability are a snapshot as of dataAsOf; the booking page is binding.'
|
||||||
|
http:
|
||||||
|
path: /mcp
|
||||||
|
# DNS rebinding protection guards unauthenticated local servers. This one is
|
||||||
|
# public, behind a bearer token, and never called from a browser.
|
||||||
|
allowed_hosts: false
|
||||||
|
registry:
|
||||||
|
tools: ['App\Chatbot\Mcp\Tool\']
|
||||||
@@ -5,6 +5,7 @@ monolog:
|
|||||||
- core
|
- core
|
||||||
- auth
|
- auth
|
||||||
- admin
|
- admin
|
||||||
|
- chatbot
|
||||||
|
|
||||||
when@dev:
|
when@dev:
|
||||||
monolog:
|
monolog:
|
||||||
@@ -33,11 +34,16 @@ when@dev:
|
|||||||
path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
|
path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
|
||||||
level: debug
|
level: debug
|
||||||
channels: ["admin"]
|
channels: ["admin"]
|
||||||
|
chatbot:
|
||||||
|
type: stream
|
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.chatbot.log"
|
||||||
|
level: debug
|
||||||
|
channels: ["chatbot"]
|
||||||
main:
|
main:
|
||||||
type: stream
|
type: stream
|
||||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
level: debug
|
level: debug
|
||||||
channels: ["!event", "!bpn", "!core", "!auth", "!admin"]
|
channels: ["!event", "!bpn", "!core", "!auth", "!admin", "!chatbot"]
|
||||||
# uncomment to get logging in your browser
|
# uncomment to get logging in your browser
|
||||||
# you may have to allow bigger header sizes in your Web server configuration
|
# you may have to allow bigger header sizes in your Web server configuration
|
||||||
#firephp:
|
#firephp:
|
||||||
@@ -96,13 +102,20 @@ when@prod: &prod_config
|
|||||||
path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
|
path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
|
||||||
level: info
|
level: info
|
||||||
channels: ["admin"]
|
channels: ["admin"]
|
||||||
|
# MCP tool calls, file only: the log for judging search quality later.
|
||||||
|
chatbot:
|
||||||
|
type: rotating_file
|
||||||
|
max_files: 30
|
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.chatbot.log"
|
||||||
|
level: info
|
||||||
|
channels: ["chatbot"]
|
||||||
main:
|
main:
|
||||||
type: fingers_crossed
|
type: fingers_crossed
|
||||||
action_level: error
|
action_level: error
|
||||||
handler: nested
|
handler: nested
|
||||||
excluded_http_codes: [404, 405]
|
excluded_http_codes: [404, 405]
|
||||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||||
channels: ["!bpn", "!core", "!auth", "!admin"]
|
channels: ["!bpn", "!core", "!auth", "!admin", "!chatbot"]
|
||||||
nested:
|
nested:
|
||||||
type: rotating_file
|
type: rotating_file
|
||||||
path: "%kernel.logs_dir%/%kernel.environment%.framework.log"
|
path: "%kernel.logs_dir%/%kernel.environment%.framework.log"
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ security:
|
|||||||
mailjet:
|
mailjet:
|
||||||
password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%'
|
password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%'
|
||||||
roles: [ ROLE_MAILJET_WEBHOOK ]
|
roles: [ ROLE_MAILJET_WEBHOOK ]
|
||||||
|
mcp_provider:
|
||||||
|
memory:
|
||||||
|
users:
|
||||||
|
n8n:
|
||||||
|
roles: [ ROLE_CHATBOT_MCP ]
|
||||||
|
|
||||||
role_hierarchy:
|
role_hierarchy:
|
||||||
# ROLE_CUSTOMER_EXPERT gates the booking-draft and log surfaces, which used to be
|
# ROLE_CUSTOMER_EXPERT gates the booking-draft and log surfaces, which used to be
|
||||||
@@ -45,6 +50,12 @@ security:
|
|||||||
provider: mailjet_webhook_provider
|
provider: mailjet_webhook_provider
|
||||||
http_basic:
|
http_basic:
|
||||||
realm: 'Mailjet Webhook'
|
realm: 'Mailjet Webhook'
|
||||||
|
mcp:
|
||||||
|
pattern: ^/mcp$
|
||||||
|
stateless: true
|
||||||
|
provider: mcp_provider
|
||||||
|
access_token:
|
||||||
|
token_handler: App\Security\McpAccessTokenHandler
|
||||||
main:
|
main:
|
||||||
lazy: true
|
lazy: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
@@ -58,6 +69,7 @@ security:
|
|||||||
# Note: Only the *first* access control that matches will be used
|
# Note: Only the *first* access control that matches will be used
|
||||||
access_control:
|
access_control:
|
||||||
- { path: ^/webhooks/mailjet/newsletter$, roles: ROLE_MAILJET_WEBHOOK, requires_channel: https }
|
- { path: ^/webhooks/mailjet/newsletter$, roles: ROLE_MAILJET_WEBHOOK, requires_channel: https }
|
||||||
|
- { path: ^/mcp$, roles: ROLE_CHATBOT_MCP, requires_channel: https }
|
||||||
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https }
|
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https }
|
||||||
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
|
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
mcp:
|
||||||
|
resource: .
|
||||||
|
type: mcp
|
||||||
@@ -333,6 +333,15 @@ services:
|
|||||||
- '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider'
|
- '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider'
|
||||||
- '@App\Dashboard\Widget\RecentLogEntriesWidgetProvider'
|
- '@App\Dashboard\Widget\RecentLogEntriesWidgetProvider'
|
||||||
|
|
||||||
|
App\ContentFeed\ContentFeedClient:
|
||||||
|
arguments:
|
||||||
|
$baseUrl: '%env(APP_CMS_API_BASE_URL)%'
|
||||||
|
$apiKey: '%env(APP_CMS_API_KEY)%'
|
||||||
|
|
||||||
|
App\Security\McpAccessTokenHandler:
|
||||||
|
arguments:
|
||||||
|
$token: '%env(APP_MCP_TOKEN)%'
|
||||||
|
|
||||||
App\Service\CmsDataProvider:
|
App\Service\CmsDataProvider:
|
||||||
arguments:
|
arguments:
|
||||||
$httpClient: '@typo3.client'
|
$httpClient: '@typo3.client'
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Chatbot MCP server
|
||||||
|
|
||||||
|
The website chatbot runs in n8n. Its AI Agent answers from a read-only MCP server in this app instead
|
||||||
|
of scraping the websites. The server combines two sources:
|
||||||
|
|
||||||
|
- **Editorial content from TYPO3**: the content feed of all public sites (ep-reisen, ser,
|
||||||
|
ski-boarderweek, ski-wochenenden, snowzone), mirrored into `content_document` by `app:content:sync`.
|
||||||
|
- **Trip data of our own**: dates, rooms, prices, availability and pickups from the product export
|
||||||
|
(`ProductExportBuilder`, the data behind `GET /api/exports/products`).
|
||||||
|
|
||||||
|
TYPO3 sends no dates or prices, and nothing flows back to TYPO3.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
`POST /mcp` (Streamable HTTP; `GET`/`DELETE` are part of the transport), served by `symfony/mcp-bundle`
|
||||||
|
from `config/packages/mcp.yaml`. Tools only, no resources or prompts.
|
||||||
|
|
||||||
|
Authentication is a static bearer token, `Authorization: Bearer <APP_MCP_TOKEN>`, checked by
|
||||||
|
`App\Security\McpAccessTokenHandler` on the `mcp` firewall. If `APP_MCP_TOKEN` is empty, every request is
|
||||||
|
refused. It is not the OAuth2 client-credentials flow of `/api`: n8n's MCP Client Tool node sends a fixed
|
||||||
|
header and cannot renew a token every 10 minutes.
|
||||||
|
|
||||||
|
Tool calls are rate-limited per token (`chatbot_mcp` limiter, 60 per minute). The limit is there to stop
|
||||||
|
runaway agent loops. Each call is logged on the `chatbot` channel (`var/log/<env>.chatbot.log`, file
|
||||||
|
only) with the tool, arguments, result count and duration. Nothing identifies the person chatting.
|
||||||
|
|
||||||
|
### n8n
|
||||||
|
|
||||||
|
MCP Client Tool node: endpoint `https://<host>/mcp`, transport *HTTP Streamable*, authentication *Bearer*
|
||||||
|
with the stage's `APP_MCP_TOKEN`. The agent prompt should tell the bot to cite each answer's `url`, and
|
||||||
|
that prices and availability are a snapshot (`dataAsOf`) and only the booking page is binding.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
| Tool | Use | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `search_trips` | Which trips exist, when, where, how much | Up to 10 trips with their next 3 matching dates, `priceFrom`, availability, `dataAsOf` |
|
||||||
|
| `get_trip` | One trip by `code` (optionally `hotelCode`) | Product text with FAQs, hotels with facts, all upcoming dates with bookable rooms, pickups for the first 3 dates |
|
||||||
|
| `search_content` | Questions not about dates or prices | Up to 8 hits with a ~600-character excerpt |
|
||||||
|
| `get_document` | The full text of one `search_content` hit, by `url` | Markdown body, truncated at 20 000 characters |
|
||||||
|
|
||||||
|
Every result carries the public `url`. Output is compact JSON with Markdown in the text fields.
|
||||||
|
An empty result is `{"results": []}`. Invalid input produces a tool error the model can read.
|
||||||
|
|
||||||
|
Trip rules (`App\Chatbot\TripCatalog`):
|
||||||
|
|
||||||
|
- TYPO3 products are joined to the export on `product.code`. Several products can share a code, and
|
||||||
|
each is listed as its own trip. Hotels are joined on any entry of the TYPO3 hotel's `codes` (BusPro
|
||||||
|
uses the matchcode aliases). A code that is one hotel's own and another's alias resolves to its owner.
|
||||||
|
- A room is bookable unless its status is `Buchungsstop`. `available` counts rooms, so the places of a
|
||||||
|
date at a hotel are `Σ available × maxPax` over its bookable rooms. `persons` filters on that sum, and
|
||||||
|
`maxPrice` on the lowest bookable room price. The tools never output room counts, only `frei` or
|
||||||
|
`auf Anfrage`.
|
||||||
|
- `search_trips` only offers products that TYPO3 lists in its own website search (`searchable`) and does
|
||||||
|
not mark as unbookable. Closed group trips and staff trainings are `searchable: false`. `get_trip` still
|
||||||
|
answers when someone names their code.
|
||||||
|
- The export's `guide` (name, phone) and all BusPro ids are never output.
|
||||||
|
|
||||||
|
Search (`App\Chatbot\Search\ContentSearch`) is MariaDB full text in boolean mode over
|
||||||
|
`title, breadcrumb, body_md`. Each term becomes a prefix match, because InnoDB has no German stemming,
|
||||||
|
and German stopwords are dropped in PHP. Relevance is weighted by type: products and hotels, then
|
||||||
|
destinations and concepts, then pages, then blog posts.
|
||||||
|
|
||||||
|
## Content sync
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/console app:content:sync # delta: what the manifest says changed
|
||||||
|
php bin/console app:content:sync --refresh # also refetch items older than 7 days, oldest first
|
||||||
|
php bin/console app:content:sync --ignore-window # deliberate bulk run outside 01:00–05:00
|
||||||
|
php bin/console app:content:check-freshness # fails if the last good manifest is > 60 min old
|
||||||
|
```
|
||||||
|
|
||||||
|
Not scheduled yet. The intended schedule is a delta sync every 15 minutes, `--refresh` on Sunday at
|
||||||
|
01:00, and the freshness check hourly, so that `email_on_failure` reports a stalled sync.
|
||||||
|
|
||||||
|
The feed is read with `X-Api-Key: <APP_CMS_API_KEY>` from `APP_CMS_API_BASE_URL`. The manifest lists
|
||||||
|
every site whichever host serves it. The key is only sent to hosts of the sites the manifest names.
|
||||||
|
|
||||||
|
**Load rules.** These are agreed with the TYPO3 side, so do not tune them:
|
||||||
|
|
||||||
|
- One request at a time, and at most one page fetch per second.
|
||||||
|
- A response slower than 2 s makes the next request wait 5 s. Three slow pages in a row end the page
|
||||||
|
phase for the run.
|
||||||
|
- A `503` is TYPO3's kill switch. The run stops, and `Retry-After` pauses later runs (`content_sync_state.paused_until`).
|
||||||
|
Three consecutive 5xx responses or timeouts (10 s) also stop the run. Nothing is retried in a loop.
|
||||||
|
- Outside 01:00–05:00 Europe/Berlin a run fetches at most 50 pages and leaves the rest for later.
|
||||||
|
|
||||||
|
**Robustness.**
|
||||||
|
|
||||||
|
- A failed, invalid or empty manifest changes nothing.
|
||||||
|
- A record that is requested but absent from the records response is deleted, and so is an item that
|
||||||
|
is missing from the manifest.
|
||||||
|
- A page answering 404 or 5xx is remembered as `failed`, and one that converts to no text as `empty`.
|
||||||
|
Each is stored with its `modified` and not refetched until that changes or a `--refresh` reaches it.
|
||||||
|
- A timeout is retried on the next run.
|
||||||
|
|
||||||
|
**Document model.** One `content_document` row per `(type, uid, language)`. A record's structured
|
||||||
|
payload is kept in `data`, and HTML is converted to Markdown (`HtmlToMarkdownConverter`). A page whose
|
||||||
|
`detailOf` names a record is appended to that record's `body_md` and kept out of the index
|
||||||
|
(`merged_into`), so a product is never found twice. Merges are recomputed on every run. A record without
|
||||||
|
a reachable page (`site`/`url` null) is not shown in search results, unless a detail page lends it its URL.
|
||||||
|
|
||||||
|
After a complete run that changed something, the codes the trip join cannot place are logged on `core`
|
||||||
|
(`Content feed: unmatched codes`).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `APP_CMS_API_BASE_URL` | TYPO3 base URL per stage (also used by `CmsDataProvider`) |
|
||||||
|
| `APP_CMS_API_KEY` | TYPO3 `X-Api-Key`: the content feed and its `contentUrl` pages, and `typo3.client` (`CmsDataProvider`) |
|
||||||
|
| `APP_MCP_TOKEN` | Bearer token n8n sends to `/mcp` |
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mirror of the TYPO3 content feed behind the chatbot MCP server. No backfill: the
|
||||||
|
* first app:content:sync fills it.
|
||||||
|
*/
|
||||||
|
final class Version20261008081401 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Adds content_document and content_sync_state for the chatbot content mirror.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE content_document (id INT AUTO_INCREMENT NOT NULL, type VARCHAR(16) NOT NULL, uid INT NOT NULL, language VARCHAR(8) NOT NULL, site VARCHAR(32) DEFAULT NULL, url VARCHAR(768) DEFAULT NULL, title VARCHAR(255) NOT NULL, breadcrumb VARCHAR(512) DEFAULT NULL, body_md MEDIUMTEXT NOT NULL, data JSON DEFAULT NULL COMMENT \'(DC2Type:json)\', modified DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', fetched_modified DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', fetch_state VARCHAR(16) NOT NULL, synced_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', merged_into VARCHAR(32) DEFAULT NULL, indexed TINYINT(1) NOT NULL, INDEX content_document_indexed_site (indexed, site), INDEX content_document_url (url), FULLTEXT INDEX content_document_fulltext (title, breadcrumb, body_md), UNIQUE INDEX content_document_identity (type, uid, language), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
$this->addSql('CREATE TABLE content_sync_state (id INT AUTO_INCREMENT NOT NULL, manifest_generated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', last_run_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', last_error LONGTEXT DEFAULT NULL, paused_until DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE content_document');
|
||||||
|
$this->addSql('DROP TABLE content_sync_state');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).';
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)))];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Repository\ContentSyncStateRepository;
|
||||||
|
use Symfony\Component\Clock\ClockInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:content:check-freshness',
|
||||||
|
description: 'Fails when the last accepted content manifest is too old.',
|
||||||
|
)]
|
||||||
|
/**
|
||||||
|
* The health check of the content sync. A stalled sync otherwise shows only as a chatbot
|
||||||
|
* giving stale answers. Scheduled, a failure reaches the schedule's email_on_failure.
|
||||||
|
*/
|
||||||
|
class ContentCheckFreshnessCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContentSyncStateRepository $stateRepository,
|
||||||
|
private readonly ClockInterface $clock,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('max-age', null, InputOption::VALUE_REQUIRED, 'Maximum age of the last good manifest, in minutes', '60');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$state = $this->stateRepository->get();
|
||||||
|
$generatedAt = $state->getManifestGeneratedAt();
|
||||||
|
$maxAge = (int) $input->getOption('max-age');
|
||||||
|
|
||||||
|
if (null === $generatedAt) {
|
||||||
|
$io->error('No content manifest has been accepted yet.');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ageMinutes = (int) floor(($this->clock->now()->getTimestamp() - $generatedAt->getTimestamp()) / 60);
|
||||||
|
|
||||||
|
if ($ageMinutes > $maxAge) {
|
||||||
|
$io->error(sprintf('The last good content manifest is %d minutes old (generated %s). Last run error: %s', $ageMinutes, $generatedAt->format('c'), $state->getLastError() ?? 'none'));
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success(sprintf('The last good content manifest is %d minutes old.', $ageMinutes));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\ContentFeed\ContentSyncer;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Command\LockableTrait;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:content:sync',
|
||||||
|
description: 'Mirrors the TYPO3 content feed for the chatbot MCP server.',
|
||||||
|
)]
|
||||||
|
/**
|
||||||
|
* Console entrypoint for the content feed sync.
|
||||||
|
*
|
||||||
|
* Meant to run every 15 minutes, plus once a week with --refresh in the night window.
|
||||||
|
* Outside 01:00-05:00 Europe/Berlin a run fetches at most 50 pages; --ignore-window lifts
|
||||||
|
* that for a deliberate manual run, such as the first sync on staging.
|
||||||
|
*/
|
||||||
|
class ContentSyncCommand extends Command
|
||||||
|
{
|
||||||
|
use LockableTrait;
|
||||||
|
|
||||||
|
public function __construct(private readonly ContentSyncer $syncer)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('refresh', null, InputOption::VALUE_NONE, sprintf('Also refetch items stored more than %d days ago, oldest first', ContentSyncer::REFRESH_AFTER_DAYS))
|
||||||
|
->addOption('ignore-window', null, InputOption::VALUE_NONE, sprintf('Fetch every due page, not just %d, outside the night window', ContentSyncer::PAGES_OUTSIDE_WINDOW))
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
if (!$this->lock()) {
|
||||||
|
$io->warning('Another content sync is running.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$report = $this->syncer->run((bool) $input->getOption('refresh'), (bool) $input->getOption('ignore-window'));
|
||||||
|
} finally {
|
||||||
|
$this->release();
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->definitionList(
|
||||||
|
['Added' => $report->added],
|
||||||
|
['Deleted' => $report->deleted],
|
||||||
|
['Records fetched' => $report->records],
|
||||||
|
['Pages fetched' => $report->pages],
|
||||||
|
['Failed' => $report->failed],
|
||||||
|
['Pages deferred' => $report->deferred],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (null !== $report->pagePhaseEndedBy) {
|
||||||
|
$io->warning('Page phase ended early: '.$report->pagePhaseEndedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $report->stoppedBy) {
|
||||||
|
$io->error('Run stopped: '.$report->stoppedBy);
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success('Content feed synced.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ContentFeed;
|
||||||
|
|
||||||
|
use App\ContentFeed\Exception\ContentFeedStoppedException;
|
||||||
|
use App\ContentFeed\Exception\ContentFeedUnavailableException;
|
||||||
|
use App\ContentFeed\Exception\ContentRequestFailedException;
|
||||||
|
use App\ContentFeed\Exception\PagePhaseEndedException;
|
||||||
|
use App\Model\ContentFeed\ContentManifest;
|
||||||
|
use App\Model\ContentFeed\ManifestItem;
|
||||||
|
use App\Model\ContentFeed\PageFetchResult;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Clock\ClockInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the TYPO3 content feed: the manifest, records by uid, and rendered page fragments.
|
||||||
|
*
|
||||||
|
* The website is an old TYPO3 install on which every page fetch is a full frontend render,
|
||||||
|
* so the load rules here are a contract with its maintainers, not tuning:
|
||||||
|
*
|
||||||
|
* - one request at a time, never parallel;
|
||||||
|
* - at most one page fetch per second;
|
||||||
|
* - after a response slower than 2 s, wait 5 s; after three in a row, no more pages this run;
|
||||||
|
* - a 503 (the kill switch) or three consecutive 5xx responses or timeouts end the run.
|
||||||
|
*
|
||||||
|
* One instance serves one run: call startRun() first, it resets the counters.
|
||||||
|
*/
|
||||||
|
class ContentFeedClient
|
||||||
|
{
|
||||||
|
public const RECORDS_BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
private const TIMEOUT_SECONDS = 10.0;
|
||||||
|
private const SLOW_SECONDS = 2.0;
|
||||||
|
private const SLOW_PAUSE_SECONDS = 5.0;
|
||||||
|
private const PAGE_INTERVAL_SECONDS = 1.0;
|
||||||
|
private const MAX_CONSECUTIVE_FAILURES = 3;
|
||||||
|
private const MAX_CONSECUTIVE_SLOW = 3;
|
||||||
|
|
||||||
|
private int $consecutiveFailures = 0;
|
||||||
|
private int $consecutiveSlow = 0;
|
||||||
|
private ?\DateTimeImmutable $notBefore = null;
|
||||||
|
private ?\DateTimeImmutable $lastPageAt = null;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $siteHosts = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly HttpClientInterface $httpClient,
|
||||||
|
private readonly ClockInterface $clock,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
private readonly string $baseUrl,
|
||||||
|
private readonly string $apiKey,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startRun(): void
|
||||||
|
{
|
||||||
|
$this->consecutiveFailures = 0;
|
||||||
|
$this->consecutiveSlow = 0;
|
||||||
|
$this->notBefore = null;
|
||||||
|
$this->lastPageAt = null;
|
||||||
|
$this->siteHosts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole manifest. Anything but a usable one stops the run: an empty TYPO3 is a bug,
|
||||||
|
* not a deletion.
|
||||||
|
*
|
||||||
|
* @throws ContentFeedStoppedException
|
||||||
|
*/
|
||||||
|
public function manifest(): ContentManifest
|
||||||
|
{
|
||||||
|
if ('' === $this->apiKey) {
|
||||||
|
throw new ContentFeedStoppedException('APP_CMS_API_KEY is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->send($this->apiUrl('api/content/manifest'), []);
|
||||||
|
|
||||||
|
if (null === $response || 200 !== $response['status']) {
|
||||||
|
throw new ContentFeedStoppedException(sprintf('Manifest request failed (%s)', $response['status'] ?? 'timeout'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = json_decode($response['body'], true, flags: \JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException $e) {
|
||||||
|
throw new ContentFeedStoppedException('Manifest is not valid JSON', 0, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($data) || !is_string($data['generatedAt'] ?? null) || !is_array($data['items'] ?? null) || [] === $data['items']) {
|
||||||
|
throw new ContentFeedStoppedException('Manifest has no generatedAt or no items');
|
||||||
|
}
|
||||||
|
|
||||||
|
$generatedAt = ManifestItem::localTime($data['generatedAt']);
|
||||||
|
if (null === $generatedAt) {
|
||||||
|
throw new ContentFeedStoppedException('Manifest generatedAt is unparseable');
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach ($data['items'] as $raw) {
|
||||||
|
try {
|
||||||
|
$item = ManifestItem::fromArray(is_array($raw) ? $raw : []);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
// A malformed entry would otherwise read as a deletion of whatever it was.
|
||||||
|
throw new ContentFeedStoppedException('Manifest contains an unusable item: '.$e->getMessage(), 0, $e);
|
||||||
|
}
|
||||||
|
$items[$item->key()] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hosts = [];
|
||||||
|
foreach (is_array($data['sites'] ?? null) ? $data['sites'] : [] as $site) {
|
||||||
|
$host = is_array($site) && is_string($site['base'] ?? null) ? parse_url($site['base'], \PHP_URL_HOST) : null;
|
||||||
|
if (is_string($host)) {
|
||||||
|
$hosts[] = strtolower($host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->siteHosts = array_values(array_unique($hosts));
|
||||||
|
|
||||||
|
return new ContentManifest($generatedAt, $items, $this->siteHosts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records of one type by uid, keyed by uid. A requested uid that is absent is no
|
||||||
|
* longer eligible.
|
||||||
|
*
|
||||||
|
* @param list<int> $uids at most RECORDS_BATCH_SIZE
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*
|
||||||
|
* @throws ContentRequestFailedException this batch failed, the run goes on
|
||||||
|
* @throws ContentFeedStoppedException
|
||||||
|
*/
|
||||||
|
public function records(string $type, array $uids): array
|
||||||
|
{
|
||||||
|
if (count($uids) > self::RECORDS_BATCH_SIZE) {
|
||||||
|
throw new \InvalidArgumentException(sprintf('At most %d uids per records request', self::RECORDS_BATCH_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->send($this->apiUrl('api/content/records'), ['type' => $type, 'uids' => implode(',', $uids)]);
|
||||||
|
|
||||||
|
if (null === $response) {
|
||||||
|
throw new ContentRequestFailedException(sprintf('Records request for %s timed out', $type));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (200 !== $response['status']) {
|
||||||
|
if ($response['status'] >= 500) {
|
||||||
|
throw new ContentRequestFailedException(sprintf('Records request for %s failed (%d)', $type, $response['status']));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 403 and the like: misconfiguration, which the next request would hit too.
|
||||||
|
throw new ContentFeedStoppedException(sprintf('Records request for %s answered %d', $type, $response['status']));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = json_decode($response['body'], true, flags: \JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException $e) {
|
||||||
|
throw new ContentRequestFailedException(sprintf('Records response for %s is not valid JSON', $type), 0, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything but the documented envelope fails the batch: read as an empty list, it
|
||||||
|
// would delete every record requested.
|
||||||
|
if (!is_array($data) || ($data['type'] ?? null) !== $type || !is_array($data['items'] ?? null) || !array_is_list($data['items'])) {
|
||||||
|
throw new ContentRequestFailedException(sprintf('Records response for %s has no items list', $type));
|
||||||
|
}
|
||||||
|
|
||||||
|
$records = [];
|
||||||
|
foreach ($data['items'] as $record) {
|
||||||
|
if (is_array($record) && is_int($record['uid'] ?? null)) {
|
||||||
|
$records[$record['uid']] = $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $records;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bare HTML fragment of one page, fetched at most once a second.
|
||||||
|
*
|
||||||
|
* @throws PagePhaseEndedException
|
||||||
|
* @throws ContentFeedStoppedException
|
||||||
|
*/
|
||||||
|
public function page(string $contentUrl): PageFetchResult
|
||||||
|
{
|
||||||
|
$host = parse_url($contentUrl, \PHP_URL_HOST);
|
||||||
|
|
||||||
|
// The key goes with every request, so it only ever goes to a host of the feed's own sites.
|
||||||
|
if (!is_string($host) || !in_array(strtolower($host), $this->siteHosts, true)) {
|
||||||
|
$this->logger->warning('Content feed: contentUrl outside the manifest sites, skipped', ['contentUrl' => $contentUrl]);
|
||||||
|
|
||||||
|
return PageFetchResult::failed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $this->lastPageAt) {
|
||||||
|
$this->waitUntil($this->lastPageAt->modify(sprintf('+%d ms', (int) (self::PAGE_INTERVAL_SECONDS * 1000))));
|
||||||
|
}
|
||||||
|
$this->lastPageAt = $this->clock->now();
|
||||||
|
|
||||||
|
$response = $this->send($contentUrl, [], countSlow: true);
|
||||||
|
|
||||||
|
if (null === $response) {
|
||||||
|
return PageFetchResult::transient();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (200 === $response['status']) {
|
||||||
|
return PageFetchResult::ok($response['body']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return PageFetchResult::failed($response['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends one GET under the load rules. Null on a timeout or transport error.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $query
|
||||||
|
*
|
||||||
|
* @return array{status: int, body: string}|null
|
||||||
|
*
|
||||||
|
* @throws ContentFeedStoppedException
|
||||||
|
* @throws PagePhaseEndedException
|
||||||
|
*/
|
||||||
|
private function send(string $url, array $query, bool $countSlow = false): ?array
|
||||||
|
{
|
||||||
|
if (null !== $this->notBefore) {
|
||||||
|
$this->waitUntil($this->notBefore);
|
||||||
|
$this->notBefore = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = null;
|
||||||
|
$duration = 0.0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->request('GET', $url, [
|
||||||
|
'query' => $query,
|
||||||
|
'headers' => ['X-Api-Key' => $this->apiKey],
|
||||||
|
'timeout' => self::TIMEOUT_SECONDS,
|
||||||
|
'max_duration' => self::TIMEOUT_SECONDS,
|
||||||
|
]);
|
||||||
|
$result = ['status' => $response->getStatusCode(), 'body' => $response->getContent(false)];
|
||||||
|
$duration = $this->duration($response);
|
||||||
|
} catch (TransportExceptionInterface $e) {
|
||||||
|
$this->logger->warning('Content feed: request failed', ['url' => $url, 'error' => $e->getMessage()]);
|
||||||
|
$duration = self::TIMEOUT_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $result && 503 === $result['status']) {
|
||||||
|
throw new ContentFeedUnavailableException($this->retryAfter($response));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $result || $result['status'] >= 500) {
|
||||||
|
if (++$this->consecutiveFailures >= self::MAX_CONSECUTIVE_FAILURES) {
|
||||||
|
throw new ContentFeedStoppedException(sprintf('%d consecutive 5xx responses or timeouts', self::MAX_CONSECUTIVE_FAILURES));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->consecutiveFailures = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($duration > self::SLOW_SECONDS) {
|
||||||
|
$this->notBefore = $this->clock->now()->modify(sprintf('+%d ms', (int) (self::SLOW_PAUSE_SECONDS * 1000)));
|
||||||
|
|
||||||
|
if ($countSlow && ++$this->consecutiveSlow >= self::MAX_CONSECUTIVE_SLOW) {
|
||||||
|
$this->consecutiveSlow = 0;
|
||||||
|
throw new PagePhaseEndedException(sprintf('%d consecutive responses slower than %d s', self::MAX_CONSECUTIVE_SLOW, (int) self::SLOW_SECONDS));
|
||||||
|
}
|
||||||
|
} elseif ($countSlow) {
|
||||||
|
$this->consecutiveSlow = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function duration(ResponseInterface $response): float
|
||||||
|
{
|
||||||
|
$total = $response->getInfo('total_time');
|
||||||
|
|
||||||
|
return is_float($total) || is_int($total) ? (float) $total : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function retryAfter(ResponseInterface $response): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
$value = $response->getHeaders(false)['retry-after'][0] ?? null;
|
||||||
|
|
||||||
|
if (null === $value || '' === trim($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctype_digit(trim($value))) {
|
||||||
|
return $this->clock->now()->modify(sprintf('+%d seconds', (int) trim($value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = date_create_immutable($value);
|
||||||
|
|
||||||
|
return false === $date ? null : $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function waitUntil(\DateTimeImmutable $moment): void
|
||||||
|
{
|
||||||
|
$seconds = (float) $moment->format('U.u') - (float) $this->clock->now()->format('U.u');
|
||||||
|
|
||||||
|
if ($seconds > 0) {
|
||||||
|
$this->clock->sleep($seconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function apiUrl(string $path): string
|
||||||
|
{
|
||||||
|
return rtrim($this->baseUrl, '/').'/'.$path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ContentFeed;
|
||||||
|
|
||||||
|
use App\Chatbot\TripCatalog;
|
||||||
|
use App\ContentFeed\Exception\ContentFeedStoppedException;
|
||||||
|
use App\ContentFeed\Exception\ContentFeedUnavailableException;
|
||||||
|
use App\ContentFeed\Exception\ContentRequestFailedException;
|
||||||
|
use App\ContentFeed\Exception\PagePhaseEndedException;
|
||||||
|
use App\Entity\ContentDocument;
|
||||||
|
use App\Enum\ContentFetchState;
|
||||||
|
use App\Model\ContentFeed\ContentManifest;
|
||||||
|
use App\Model\ContentFeed\ContentSyncReport;
|
||||||
|
use App\Model\ContentFeed\ManifestItem;
|
||||||
|
use App\Repository\ContentDocumentRepository;
|
||||||
|
use App\Repository\ContentSyncStateRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Clock\ClockInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the TYPO3 content feed into content_document.
|
||||||
|
*
|
||||||
|
* A run reads the manifest, removes what it no longer lists, refetches the records and
|
||||||
|
* pages whose `modified` changed, and re-merges detail pages into their records. A failed
|
||||||
|
* manifest changes nothing. Bulk page work belongs in the night window: outside it, a run
|
||||||
|
* fetches at most PAGES_OUTSIDE_WINDOW pages and leaves the rest for the next one.
|
||||||
|
*
|
||||||
|
* With `refresh`, items whose stored copy is older than REFRESH_AFTER_DAYS are refetched
|
||||||
|
* regardless of `modified`, oldest first: the backstop for the changes TYPO3 cannot see,
|
||||||
|
* such as a referenced content element changing on another page.
|
||||||
|
*/
|
||||||
|
class ContentSyncer
|
||||||
|
{
|
||||||
|
public const PAGES_OUTSIDE_WINDOW = 50;
|
||||||
|
public const REFRESH_AFTER_DAYS = 7;
|
||||||
|
|
||||||
|
private const NIGHT_WINDOW_TIMEZONE = 'Europe/Berlin';
|
||||||
|
private const NIGHT_WINDOW_FROM_HOUR = 1;
|
||||||
|
private const NIGHT_WINDOW_TO_HOUR = 5;
|
||||||
|
private const FLUSH_EVERY_PAGES = 20;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContentFeedClient $client,
|
||||||
|
private readonly ContentDocumentRepository $documentRepository,
|
||||||
|
private readonly ContentSyncStateRepository $stateRepository,
|
||||||
|
private readonly DocumentBodyBuilder $bodyBuilder,
|
||||||
|
private readonly HtmlToMarkdownConverter $converter,
|
||||||
|
private readonly TripCatalog $tripCatalog,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly ClockInterface $clock,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(bool $refresh = false, bool $ignoreWindow = false): ContentSyncReport
|
||||||
|
{
|
||||||
|
$report = new ContentSyncReport();
|
||||||
|
$state = $this->stateRepository->get();
|
||||||
|
$this->entityManager->persist($state);
|
||||||
|
$now = $this->clock->now();
|
||||||
|
|
||||||
|
if (null !== $state->getPausedUntil() && $now < $state->getPausedUntil()) {
|
||||||
|
$report->stoppedBy = 'Feed switched off by TYPO3 until '.$state->getPausedUntil()->format('c');
|
||||||
|
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state->setPausedUntil(null)->setLastRunAt($now);
|
||||||
|
$this->client->startRun();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$manifest = $this->client->manifest();
|
||||||
|
$documents = $this->applyManifest($manifest, $report);
|
||||||
|
$state->setManifestGeneratedAt($manifest->generatedAt);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$staleBefore = $refresh ? $now->modify(sprintf('-%d days', self::REFRESH_AFTER_DAYS)) : null;
|
||||||
|
$this->syncRecords($documents, $staleBefore, $report);
|
||||||
|
$this->syncPages($documents, $staleBefore, $ignoreWindow || $this->inNightWindow($now), $report);
|
||||||
|
} catch (ContentFeedUnavailableException $e) {
|
||||||
|
$state->setPausedUntil($e->retryAfter);
|
||||||
|
$report->stoppedBy = $e->getMessage();
|
||||||
|
} catch (ContentFeedStoppedException $e) {
|
||||||
|
$report->stoppedBy = $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($documents)) {
|
||||||
|
$this->merge();
|
||||||
|
}
|
||||||
|
|
||||||
|
$state->setLastError($report->stoppedBy ?? $report->pagePhaseEndedBy);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
if (null === $report->stoppedBy) {
|
||||||
|
$this->logger->info('Content feed sync finished', $report->toLogContext());
|
||||||
|
} else {
|
||||||
|
$this->logger->warning('Content feed sync stopped', $report->toLogContext());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($report->isComplete() && $report->changedAnything()) {
|
||||||
|
$this->tripCatalog->logUnmatchedCodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes what the manifest no longer lists and brings every listed item's metadata up
|
||||||
|
* to date. Content is not touched here.
|
||||||
|
*
|
||||||
|
* @return array<string, array{document: ContentDocument, item: ManifestItem}>
|
||||||
|
*/
|
||||||
|
private function applyManifest(ContentManifest $manifest, ContentSyncReport $report): array
|
||||||
|
{
|
||||||
|
$stored = $this->documentRepository->findAllByKey();
|
||||||
|
|
||||||
|
foreach (array_diff_key($stored, $manifest->items) as $document) {
|
||||||
|
$this->entityManager->remove($document);
|
||||||
|
++$report->deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
$documents = [];
|
||||||
|
|
||||||
|
foreach ($manifest->items as $key => $item) {
|
||||||
|
$document = $stored[$key] ?? null;
|
||||||
|
|
||||||
|
if (null === $document) {
|
||||||
|
$document = new ContentDocument($item->type, $item->uid, $item->language, $item->modified);
|
||||||
|
$this->entityManager->persist($document);
|
||||||
|
++$report->added;
|
||||||
|
}
|
||||||
|
|
||||||
|
$document->setModified($item->modified)->setSite($item->site)->setUrl($item->url);
|
||||||
|
|
||||||
|
if ($document->isPage()) {
|
||||||
|
$document
|
||||||
|
->setTitle($item->title ?? '')
|
||||||
|
->setBreadcrumb([] === $item->breadcrumb ? null : implode(' › ', $item->breadcrumb))
|
||||||
|
->setData(['contentUrl' => $item->contentUrl, 'description' => $item->description, 'detailOf' => $item->detailOf]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$documents[$key] = ['document' => $document, 'item' => $item];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $documents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array{document: ContentDocument, item: ManifestItem}> $documents
|
||||||
|
*/
|
||||||
|
private function syncRecords(array $documents, ?\DateTimeImmutable $staleBefore, ContentSyncReport $report): void
|
||||||
|
{
|
||||||
|
/** @var array<string, array<int, list<ContentDocument>>> $stale type => uid => documents; the records endpoint takes no language */
|
||||||
|
$stale = [];
|
||||||
|
|
||||||
|
foreach ($this->dueForFetch($documents, $staleBefore, pages: false) as $document) {
|
||||||
|
$stale[$document->getType()][$document->getUid()][] = $document;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($stale as $type => $byUid) {
|
||||||
|
foreach (array_chunk(array_keys($byUid), ContentFeedClient::RECORDS_BATCH_SIZE) as $uids) {
|
||||||
|
try {
|
||||||
|
$records = $this->client->records($type, $uids);
|
||||||
|
} catch (ContentRequestFailedException $e) {
|
||||||
|
$this->logger->warning('Content feed: records batch failed, retried next run', ['type' => $type, 'error' => $e->getMessage()]);
|
||||||
|
$report->failed += count($uids);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($uids as $uid) {
|
||||||
|
foreach ($byUid[$uid] as $document) {
|
||||||
|
$record = $records[$uid] ?? null;
|
||||||
|
|
||||||
|
if (null === $record) {
|
||||||
|
// Requested but absent: no longer eligible.
|
||||||
|
$this->entityManager->remove($document);
|
||||||
|
++$report->deleted;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->applyRecord($document, $record);
|
||||||
|
++$report->records;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $record
|
||||||
|
*/
|
||||||
|
private function applyRecord(ContentDocument $document, array $record): void
|
||||||
|
{
|
||||||
|
$title = $this->bodyBuilder->title($document->getType(), $record);
|
||||||
|
|
||||||
|
$document
|
||||||
|
->setData($record)
|
||||||
|
->setTitle('' !== $title ? $title : $document->getTitle())
|
||||||
|
// The merged pages are appended in merge().
|
||||||
|
->setBodyMd($this->bodyBuilder->build($document->getType(), $record))
|
||||||
|
->markFetched(ContentFetchState::Ok, $this->clock->now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array{document: ContentDocument, item: ManifestItem}> $documents
|
||||||
|
*
|
||||||
|
* @throws ContentFeedStoppedException
|
||||||
|
*/
|
||||||
|
private function syncPages(array $documents, ?\DateTimeImmutable $staleBefore, bool $unlimited, ContentSyncReport $report): void
|
||||||
|
{
|
||||||
|
$due = $this->dueForFetch($documents, $staleBefore, pages: true);
|
||||||
|
|
||||||
|
if (!$unlimited && count($due) > self::PAGES_OUTSIDE_WINDOW) {
|
||||||
|
$report->deferred = count($due) - self::PAGES_OUTSIDE_WINDOW;
|
||||||
|
$due = array_slice($due, 0, self::PAGES_OUTSIDE_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fetched = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach ($due as $document) {
|
||||||
|
$this->fetchPage($document, $report);
|
||||||
|
|
||||||
|
if (0 === ++$fetched % self::FLUSH_EVERY_PAGES) {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (PagePhaseEndedException $e) {
|
||||||
|
$report->pagePhaseEndedBy = $e->getMessage();
|
||||||
|
$this->logger->warning('Content feed: page phase ended early', ['reason' => $e->getMessage()]);
|
||||||
|
} finally {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchPage(ContentDocument $document, ContentSyncReport $report): void
|
||||||
|
{
|
||||||
|
$contentUrl = $document->getData()['contentUrl'] ?? null;
|
||||||
|
|
||||||
|
if (!is_string($contentUrl)) {
|
||||||
|
$document->markFetched(ContentFetchState::Failed, $this->clock->now());
|
||||||
|
++$report->failed;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->client->page($contentUrl);
|
||||||
|
|
||||||
|
if ($result->transient) {
|
||||||
|
++$report->failed;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$result->isOk()) {
|
||||||
|
$this->logger->notice('Content feed: page failed, not refetched until it changes', [
|
||||||
|
'uid' => $document->getUid(),
|
||||||
|
'contentUrl' => $contentUrl,
|
||||||
|
'status' => $result->failedStatus,
|
||||||
|
]);
|
||||||
|
$document->markFetched(ContentFetchState::Failed, $this->clock->now());
|
||||||
|
++$report->failed;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$markdown = $this->converter->convert($result->html, $document->getUrl() ?? $contentUrl);
|
||||||
|
$document->setBodyMd($markdown)->markFetched('' === $markdown ? ContentFetchState::Empty : ContentFetchState::Ok, $this->clock->now());
|
||||||
|
++$report->pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Items whose `modified` changed since their last fetch, plus, on a refresh, those
|
||||||
|
* fetched before `$staleBefore`, oldest first.
|
||||||
|
*
|
||||||
|
* @param array<string, array{document: ContentDocument, item: ManifestItem}> $documents
|
||||||
|
*
|
||||||
|
* @return list<ContentDocument>
|
||||||
|
*/
|
||||||
|
private function dueForFetch(array $documents, ?\DateTimeImmutable $staleBefore, bool $pages): array
|
||||||
|
{
|
||||||
|
$due = [];
|
||||||
|
|
||||||
|
foreach ($documents as ['document' => $document]) {
|
||||||
|
if ($document->isPage() !== $pages || !$this->entityManager->contains($document)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$outdated = null !== $staleBefore && (null === $document->getSyncedAt() || $document->getSyncedAt() < $staleBefore);
|
||||||
|
|
||||||
|
if ($document->isStale() || $outdated) {
|
||||||
|
$due[] = $document;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($due, static fn (ContentDocument $a, ContentDocument $b): int => ($a->getSyncedAt()?->getTimestamp() ?? 0) <=> ($b->getSyncedAt()?->getTimestamp() ?? 0));
|
||||||
|
|
||||||
|
return $due;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends every detail page to the record it is the detail page of, and keeps the page
|
||||||
|
* itself out of the index. Recomputed from scratch on every run, so a change or deletion
|
||||||
|
* on either side re-merges.
|
||||||
|
*/
|
||||||
|
private function merge(): void
|
||||||
|
{
|
||||||
|
$documents = $this->documentRepository->findAllByKey();
|
||||||
|
/** @var array<string, list<ContentDocument>> $pagesByRecord */
|
||||||
|
$pagesByRecord = [];
|
||||||
|
|
||||||
|
foreach ($documents as $page) {
|
||||||
|
if (!$page->isPage()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = null;
|
||||||
|
foreach ($page->getData()['detailOf'] ?? [] as $ref) {
|
||||||
|
$candidate = $documents[ContentDocument::key($ref['type'], $ref['uid'], $page->getLanguage())] ?? null;
|
||||||
|
|
||||||
|
if (null !== $candidate && ContentFetchState::Ok === $candidate->getFetchState()) {
|
||||||
|
$target = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$page->setMergedInto($target?->getRecordRef());
|
||||||
|
|
||||||
|
if (null !== $target && ContentFetchState::Ok === $page->getFetchState() && '' !== $page->getBodyMd()) {
|
||||||
|
$pagesByRecord[$target->getKey()][] = $page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($documents as $record) {
|
||||||
|
if ($record->isPage() || ContentFetchState::Ok !== $record->getFetchState() || null === $record->getData()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pages = $pagesByRecord[$record->getKey()] ?? [];
|
||||||
|
usort($pages, static fn (ContentDocument $a, ContentDocument $b): int => $a->getUid() <=> $b->getUid());
|
||||||
|
|
||||||
|
$body = $this->bodyBuilder->build($record->getType(), $record->getData());
|
||||||
|
foreach ($pages as $page) {
|
||||||
|
$body .= "\n\n".$page->getBodyMd();
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->setBodyMd(trim($body));
|
||||||
|
|
||||||
|
// A record without a reachable page of its own is linkable through its detail page.
|
||||||
|
if (null === $record->getUrl() && [] !== $pages) {
|
||||||
|
$record->setUrl($pages[0]->getUrl())->setSite($record->getSite() ?? $pages[0]->getSite());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function inNightWindow(\DateTimeImmutable $now): bool
|
||||||
|
{
|
||||||
|
$hour = (int) $now->setTimezone(new \DateTimeZone(self::NIGHT_WINDOW_TIMEZONE))->format('G');
|
||||||
|
|
||||||
|
return $hour >= self::NIGHT_WINDOW_FROM_HOUR && $hour < self::NIGHT_WINDOW_TO_HOUR;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ContentFeed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the searchable Markdown body and the title of a feed record.
|
||||||
|
*
|
||||||
|
* Every record follows the same pattern: the name as heading, the short introductory
|
||||||
|
* fields as paragraphs, then each `text` field as a section under a German heading, in a
|
||||||
|
* fixed order. Structured facts that people ask about (WLAN, Sauna, Pistenkilometer) are
|
||||||
|
* rendered as a list as well, so the full-text search finds them.
|
||||||
|
*/
|
||||||
|
class DocumentBodyBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Introductory fields, rendered without a heading. Plain text in the feed.
|
||||||
|
*/
|
||||||
|
private const LEAD_FIELDS = ['subline', 'headline'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `text` fields per type, in output order, with their section heading. A null heading
|
||||||
|
* renders the field as an introduction straight under the title.
|
||||||
|
*/
|
||||||
|
private const TEXT_SECTIONS = [
|
||||||
|
'product' => [
|
||||||
|
'teaser' => null,
|
||||||
|
'teaserLong' => 'Beschreibung',
|
||||||
|
'feature' => 'Highlights',
|
||||||
|
'conceptDescription' => 'Reisekonzept',
|
||||||
|
'boardDescription' => 'Verpflegung',
|
||||||
|
'includedServices' => 'Leistungen',
|
||||||
|
'additionalServices' => 'Zusatzleistungen',
|
||||||
|
'programmeDescription' => 'Programm',
|
||||||
|
'skiPassDescription' => 'Skipass',
|
||||||
|
'nonBookableInfo' => 'Hinweise zur Buchbarkeit',
|
||||||
|
],
|
||||||
|
'hotel' => [
|
||||||
|
'teaser' => null,
|
||||||
|
'description' => 'Beschreibung',
|
||||||
|
'features' => 'Ausstattung',
|
||||||
|
'roomTypes' => 'Zimmer',
|
||||||
|
'additionalInformation' => 'Weitere Informationen',
|
||||||
|
],
|
||||||
|
'region' => [
|
||||||
|
'teaser' => null,
|
||||||
|
'description' => 'Beschreibung',
|
||||||
|
'feature' => 'Highlights',
|
||||||
|
'skiArea' => 'Skigebiet',
|
||||||
|
'skiAreaExtended' => 'Skigebiet im Detail',
|
||||||
|
'skiPass' => 'Skipass',
|
||||||
|
'news' => 'Neuigkeiten',
|
||||||
|
],
|
||||||
|
'country' => [
|
||||||
|
'teaser' => null,
|
||||||
|
'description' => 'Beschreibung',
|
||||||
|
'descriptionExtended' => 'Mehr erfahren',
|
||||||
|
],
|
||||||
|
'city' => [
|
||||||
|
'teaser' => null,
|
||||||
|
'description' => 'Beschreibung',
|
||||||
|
'descriptionExtended' => 'Mehr erfahren',
|
||||||
|
'party' => 'Party',
|
||||||
|
'leisure' => 'Freizeit',
|
||||||
|
],
|
||||||
|
'concept' => [
|
||||||
|
'teaser' => null,
|
||||||
|
'description' => 'Beschreibung',
|
||||||
|
'descriptionExtended' => 'Mehr erfahren',
|
||||||
|
'popupText' => 'Kurz erklärt',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Facts per type, with their German label.
|
||||||
|
*/
|
||||||
|
private const FACTS = [
|
||||||
|
'hotel' => [
|
||||||
|
'bedsCount' => 'Betten',
|
||||||
|
'parkingCount' => 'Parkplätze',
|
||||||
|
'altitude' => 'Höhenlage',
|
||||||
|
'distanceBus' => 'Entfernung zur Bushaltestelle',
|
||||||
|
'distanceLift' => 'Entfernung zum Lift',
|
||||||
|
'shower' => 'Dusche/WC',
|
||||||
|
'partyroom' => 'Partyraum',
|
||||||
|
'gamesroom' => 'Spieleraum',
|
||||||
|
'sauna' => 'Sauna',
|
||||||
|
'wifi' => 'WLAN',
|
||||||
|
],
|
||||||
|
'region' => [
|
||||||
|
'length' => 'Pistenkilometer',
|
||||||
|
'height' => 'Höhe',
|
||||||
|
'lifts' => 'Lifte',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(private readonly HtmlToMarkdownConverter $converter)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $record
|
||||||
|
*/
|
||||||
|
public function title(string $type, array $record): string
|
||||||
|
{
|
||||||
|
$title = 'news' === $type ? ($record['title'] ?? null) : ($record['name'] ?? null);
|
||||||
|
|
||||||
|
return is_string($title) ? trim($title) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $record
|
||||||
|
*/
|
||||||
|
public function build(string $type, array $record): string
|
||||||
|
{
|
||||||
|
$baseUrl = is_string($record['url'] ?? null) ? $record['url'] : null;
|
||||||
|
$blocks = [];
|
||||||
|
|
||||||
|
$title = $this->title($type, $record);
|
||||||
|
if ('' !== $title) {
|
||||||
|
$blocks[] = '# '.$title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('news' === $type) {
|
||||||
|
return $this->join(array_merge($blocks, $this->newsBlocks($record, $baseUrl)));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::LEAD_FIELDS as $field) {
|
||||||
|
$value = $this->plain($record[$field] ?? null);
|
||||||
|
if ('' !== $value && $value !== $title) {
|
||||||
|
$blocks[] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('hotel' === $type) {
|
||||||
|
$address = $this->plain($record['address'] ?? null);
|
||||||
|
if ('' !== $address) {
|
||||||
|
$blocks[] = 'Adresse: '.$address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = is_array($record['text'] ?? null) ? $record['text'] : [];
|
||||||
|
foreach (self::TEXT_SECTIONS[$type] ?? [] as $field => $heading) {
|
||||||
|
$markdown = $this->converter->convert(is_string($text[$field] ?? null) ? $text[$field] : null, $baseUrl);
|
||||||
|
if ('' === $markdown) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$blocks[] = null === $heading ? $markdown : "## {$heading}\n\n{$markdown}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$facts = $this->facts($type, is_array($record['facts'] ?? null) ? $record['facts'] : []);
|
||||||
|
if ('' !== $facts) {
|
||||||
|
$blocks[] = "## Fakten\n\n".$facts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('region' === $type && is_string($record['webcam'] ?? null) && '' !== trim($record['webcam'])) {
|
||||||
|
$blocks[] = 'Webcam: '.trim($record['webcam']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('product' === $type) {
|
||||||
|
$faqs = $this->faqs(is_array($record['faqs'] ?? null) ? $record['faqs'] : [], $baseUrl);
|
||||||
|
if ('' !== $faqs) {
|
||||||
|
$blocks[] = "## Häufige Fragen\n\n".$faqs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->join($blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $record
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function newsBlocks(array $record, ?string $baseUrl): array
|
||||||
|
{
|
||||||
|
$blocks = [];
|
||||||
|
|
||||||
|
if (is_string($record['datetime'] ?? null) && false !== ($date = date_create_immutable($record['datetime']))) {
|
||||||
|
$blocks[] = 'Veröffentlicht am '.$date->format('d.m.Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['teaser', 'bodytext'] as $field) {
|
||||||
|
$markdown = $this->converter->convert(is_string($record[$field] ?? null) ? $record[$field] : null, $baseUrl);
|
||||||
|
if ('' !== $markdown) {
|
||||||
|
$blocks[] = $markdown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tags = $this->labels($record['tags'] ?? null);
|
||||||
|
if ([] !== $tags) {
|
||||||
|
$blocks[] = 'Themen: '.implode(', ', $tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $facts
|
||||||
|
*/
|
||||||
|
private function facts(string $type, array $facts): string
|
||||||
|
{
|
||||||
|
$lines = [];
|
||||||
|
|
||||||
|
foreach (self::FACTS[$type] ?? [] as $field => $label) {
|
||||||
|
$value = $facts[$field] ?? null;
|
||||||
|
|
||||||
|
$rendered = match (true) {
|
||||||
|
is_bool($value) => $value ? 'ja' : 'nein',
|
||||||
|
is_int($value), is_float($value) => (string) $value,
|
||||||
|
is_string($value) => $this->plain($value),
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if ('' !== $rendered) {
|
||||||
|
$lines[] = "- {$label}: {$rendered}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<mixed> $faqs
|
||||||
|
*/
|
||||||
|
private function faqs(array $faqs, ?string $baseUrl): string
|
||||||
|
{
|
||||||
|
$blocks = [];
|
||||||
|
|
||||||
|
foreach ($faqs as $faq) {
|
||||||
|
if (!is_array($faq)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$question = $this->plain($faq['question'] ?? null);
|
||||||
|
$answer = $this->converter->convert(is_string($faq['answer'] ?? null) ? $faq['answer'] : null, $baseUrl);
|
||||||
|
|
||||||
|
if ('' !== $question && '' !== $answer) {
|
||||||
|
$blocks[] = "### {$question}\n\n{$answer}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->join($blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tags and categories may arrive as strings or as objects with a title or name.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function labels(mixed $values): array
|
||||||
|
{
|
||||||
|
if (!is_array($values)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
foreach ($values as $value) {
|
||||||
|
$label = is_array($value) ? ($value['title'] ?? $value['name'] ?? null) : $value;
|
||||||
|
if (is_string($label) && '' !== trim($label)) {
|
||||||
|
$labels[] = trim($label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A field that is plain text in the feed, defensively stripped of any markup.
|
||||||
|
*/
|
||||||
|
private function plain(mixed $value): string
|
||||||
|
{
|
||||||
|
if (!is_string($value)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($value), \ENT_QUOTES | \ENT_HTML5, 'UTF-8')));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $blocks
|
||||||
|
*/
|
||||||
|
private function join(array $blocks): string
|
||||||
|
{
|
||||||
|
return implode("\n\n", $blocks);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ContentFeed\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the whole sync run, keeping everything as it is. The next scheduled run tries again.
|
||||||
|
*
|
||||||
|
* Thrown for a manifest that cannot be trusted, three consecutive 5xx responses or
|
||||||
|
* timeouts, and anything else that means TYPO3 is not in a state to be read.
|
||||||
|
*/
|
||||||
|
class ContentFeedStoppedException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ContentFeed\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TYPO3 answered 503: the feed has been switched off on its side.
|
||||||
|
*/
|
||||||
|
class ContentFeedUnavailableException extends ContentFeedStoppedException
|
||||||
|
{
|
||||||
|
public function __construct(public readonly ?\DateTimeImmutable $retryAfter)
|
||||||
|
{
|
||||||
|
parent::__construct('TYPO3 switched the content feed off (503)'.(null !== $retryAfter ? ', retry after '.$retryAfter->format('c') : ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ContentFeed\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One request failed without ending the run. The items it was for keep their stored
|
||||||
|
* version and are retried next run.
|
||||||
|
*/
|
||||||
|
class ContentRequestFailedException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ContentFeed\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three slow responses in a row: TYPO3 is under load, so no more pages this run. The rest
|
||||||
|
* of the run (bookkeeping, merging) still completes.
|
||||||
|
*/
|
||||||
|
class PagePhaseEndedException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ContentFeed;
|
||||||
|
|
||||||
|
use League\HTMLToMarkdown\Converter\TableConverter;
|
||||||
|
use League\HTMLToMarkdown\HtmlConverter;
|
||||||
|
use Symfony\Component\DomCrawler\UriResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the CMS's HTML, record text fields and page fragments alike, into Markdown for the
|
||||||
|
* index and the tool output. LLMs read Markdown well; HTML noise only costs tokens.
|
||||||
|
*
|
||||||
|
* Embeds, media and forms are dropped with their content. Relative links are made
|
||||||
|
* absolute against the document's own URL, since the page fragment may carry them.
|
||||||
|
*/
|
||||||
|
class HtmlToMarkdownConverter
|
||||||
|
{
|
||||||
|
private const REMOVED_ELEMENTS = ['script', 'style', 'noscript', 'iframe', 'img', 'picture', 'svg', 'form'];
|
||||||
|
|
||||||
|
private readonly HtmlConverter $converter;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->converter = new HtmlConverter([
|
||||||
|
'header_style' => 'atx',
|
||||||
|
'strip_tags' => true,
|
||||||
|
'strip_placeholder_links' => true,
|
||||||
|
'hard_break' => true,
|
||||||
|
'suppress_errors' => true,
|
||||||
|
'remove_nodes' => implode(' ', self::REMOVED_ELEMENTS),
|
||||||
|
]);
|
||||||
|
$this->converter->getEnvironment()->addConverter(new TableConverter());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $baseUrl what relative links resolve against; left as they are without one
|
||||||
|
*/
|
||||||
|
public function convert(?string $html, ?string $baseUrl = null): string
|
||||||
|
{
|
||||||
|
if (null === $html || '' === trim($html)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$markdown = $this->converter->convert($this->prepare($html, $baseUrl));
|
||||||
|
|
||||||
|
return $this->collapseWhitespace($markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function prepare(string $html, ?string $baseUrl): string
|
||||||
|
{
|
||||||
|
$document = \Dom\HTMLDocument::createFromString('<!DOCTYPE html><html><body>'.$html.'</body></html>', \LIBXML_NOERROR, 'UTF-8');
|
||||||
|
$body = $document->body;
|
||||||
|
|
||||||
|
if (null === $body) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($body->querySelectorAll(implode(',', self::REMOVED_ELEMENTS)) as $element) {
|
||||||
|
$element->remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitespace in text collapses as a browser would; the template's indentation would
|
||||||
|
// otherwise end up inside link labels and headings.
|
||||||
|
foreach ((new \Dom\XPath($document))->query('//body//text()[not(ancestor::pre)]') as $text) {
|
||||||
|
if (!$text instanceof \Dom\Text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$text->data = (string) preg_replace('/[ \t\r\n]+/', ' ', $text->data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $baseUrl) {
|
||||||
|
foreach ($body->querySelectorAll('a[href]') as $link) {
|
||||||
|
$href = trim((string) $link->getAttribute('href'));
|
||||||
|
|
||||||
|
if ('' === $href || preg_match('/^(mailto|tel|javascript):/i', $href)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$link->setAttribute('href', UriResolver::resolve($href, $baseUrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $body->innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collapseWhitespace(string $markdown): string
|
||||||
|
{
|
||||||
|
// The converter leaves text entity-encoded (`&`), which only costs tokens.
|
||||||
|
$markdown = html_entity_decode($markdown, \ENT_QUOTES | \ENT_HTML5, 'UTF-8');
|
||||||
|
$markdown = str_replace(["\r\n", "\r", "\u{00A0}"], ["\n", "\n", ' '], $markdown);
|
||||||
|
|
||||||
|
// Labels padded by the collapsed whitespace: "[ Kontakt ](…)" -> "[Kontakt](…)".
|
||||||
|
$markdown = (string) preg_replace(['/\[ +/', '/ +\]\(/'], ['[', ']('], $markdown);
|
||||||
|
|
||||||
|
$lines = array_map(
|
||||||
|
// Inner runs only: leading spaces carry list nesting.
|
||||||
|
static fn (string $line): string => rtrim((string) preg_replace('/(?<=\S)[ \t]{2,}/', ' ', $line)),
|
||||||
|
explode("\n", $markdown),
|
||||||
|
);
|
||||||
|
|
||||||
|
return trim((string) preg_replace("/\n{3,}/", "\n\n", implode("\n", $lines)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Enum\ContentFetchState;
|
||||||
|
use App\Repository\ContentDocumentRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One item of the TYPO3 content feed, mirrored for the chatbot's MCP tools.
|
||||||
|
*
|
||||||
|
* Identity is TYPO3's `(type, uid, language)`. A record (product, hotel, destination,
|
||||||
|
* concept, news) carries its structured payload in `data` as received; a page carries its
|
||||||
|
* manifest entry there, including `contentUrl` and `detailOf`.
|
||||||
|
*
|
||||||
|
* A page that is the detail page of a record is merged into it: its text is appended to the
|
||||||
|
* record's `bodyMd`, and the page itself names the record in `mergedInto` and stays out of
|
||||||
|
* the index, so a product does not turn up twice with half its text each time.
|
||||||
|
*
|
||||||
|
* `indexed` is derived, never set: only documents that were fetched, have text and are not
|
||||||
|
* merged into another are searched. A product TYPO3 keeps out of its own website search
|
||||||
|
* (`searchable: false`: closed group trips, staff trainings) stays out of this one too.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: ContentDocumentRepository::class)]
|
||||||
|
#[ORM\Table(name: 'content_document')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'content_document_identity', columns: ['type', 'uid', 'language'])]
|
||||||
|
#[ORM\Index(name: 'content_document_indexed_site', columns: ['indexed', 'site'])]
|
||||||
|
#[ORM\Index(name: 'content_document_url', columns: ['url'])]
|
||||||
|
#[ORM\Index(name: 'content_document_fulltext', columns: ['title', 'breadcrumb', 'body_md'], flags: ['fulltext'])]
|
||||||
|
class ContentDocument
|
||||||
|
{
|
||||||
|
public const TYPE_PAGE = 'page';
|
||||||
|
public const RECORD_TYPES = ['product', 'hotel', 'region', 'country', 'city', 'concept', 'news'];
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 16)]
|
||||||
|
private string $type;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $uid;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 8)]
|
||||||
|
private string $language;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Null for a record without a reachable detail page.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(length: 32, nullable: true)]
|
||||||
|
private ?string $site = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public URL to cite. Null for a record without a reachable detail page.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(length: 768, nullable: true)]
|
||||||
|
private ?string $url = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private string $title = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages only, joined with ›.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(length: 512, nullable: true)]
|
||||||
|
private ?string $breadcrumb = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, length: 16777215)]
|
||||||
|
private string $bodyMd = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||||
|
private ?array $data = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The manifest's `modified`, as last seen.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||||
|
private \DateTimeImmutable $modified;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The manifest's `modified` that the stored fetch outcome belongs to. The item is stale
|
||||||
|
* while this differs from `modified`.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $fetchedModified = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 16, enumType: ContentFetchState::class)]
|
||||||
|
private ContentFetchState $fetchState = ContentFetchState::Pending;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last fetch attempt that produced an outcome, whichever it was.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $syncedAt = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `type:uid` of the record this page is the detail page of, in the same language.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(length: 32, nullable: true)]
|
||||||
|
private ?string $mergedInto = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private bool $indexed = false;
|
||||||
|
|
||||||
|
public function __construct(string $type, int $uid, string $language, \DateTimeImmutable $modified)
|
||||||
|
{
|
||||||
|
$this->type = $type;
|
||||||
|
$this->uid = $uid;
|
||||||
|
$this->language = $language;
|
||||||
|
$this->modified = $modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function key(string $type, int $uid, string $language): string
|
||||||
|
{
|
||||||
|
return $type.':'.$uid.':'.$language;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getKey(): string
|
||||||
|
{
|
||||||
|
return self::key($this->type, $this->uid, $this->language);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `type:uid` a merged page refers to its record by.
|
||||||
|
*/
|
||||||
|
public function getRecordRef(): string
|
||||||
|
{
|
||||||
|
return $this->type.':'.$this->uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPage(): bool
|
||||||
|
{
|
||||||
|
return self::TYPE_PAGE === $this->type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isStale(): bool
|
||||||
|
{
|
||||||
|
return null === $this->fetchedModified || $this->fetchedModified != $this->modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getType(): string
|
||||||
|
{
|
||||||
|
return $this->type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUid(): int
|
||||||
|
{
|
||||||
|
return $this->uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLanguage(): string
|
||||||
|
{
|
||||||
|
return $this->language;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSite(): ?string
|
||||||
|
{
|
||||||
|
return $this->site;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSite(?string $site): self
|
||||||
|
{
|
||||||
|
$this->site = $site;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUrl(?string $url): self
|
||||||
|
{
|
||||||
|
$this->url = $url;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return $this->title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTitle(string $title): self
|
||||||
|
{
|
||||||
|
$this->title = mb_substr($title, 0, 255);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBreadcrumb(): ?string
|
||||||
|
{
|
||||||
|
return $this->breadcrumb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBreadcrumb(?string $breadcrumb): self
|
||||||
|
{
|
||||||
|
$this->breadcrumb = null === $breadcrumb ? null : mb_substr($breadcrumb, 0, 512);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBodyMd(): string
|
||||||
|
{
|
||||||
|
return $this->bodyMd;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBodyMd(string $bodyMd): self
|
||||||
|
{
|
||||||
|
$this->bodyMd = $bodyMd;
|
||||||
|
$this->updateIndexed();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function getData(): ?array
|
||||||
|
{
|
||||||
|
return $this->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $data
|
||||||
|
*/
|
||||||
|
public function setData(?array $data): self
|
||||||
|
{
|
||||||
|
$this->data = $data;
|
||||||
|
$this->updateIndexed();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getModified(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setModified(\DateTimeImmutable $modified): self
|
||||||
|
{
|
||||||
|
// Compared by value: assigning an equal instance would still schedule an update.
|
||||||
|
if ($modified != $this->modified) {
|
||||||
|
$this->modified = $modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFetchedModified(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->fetchedModified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFetchState(): ContentFetchState
|
||||||
|
{
|
||||||
|
return $this->fetchState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSyncedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->syncedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the outcome of a fetch against the `modified` it was made for.
|
||||||
|
*/
|
||||||
|
public function markFetched(ContentFetchState $state, \DateTimeImmutable $at): self
|
||||||
|
{
|
||||||
|
$this->fetchState = $state;
|
||||||
|
$this->fetchedModified = $this->modified;
|
||||||
|
$this->syncedAt = $at;
|
||||||
|
$this->updateIndexed();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMergedInto(): ?string
|
||||||
|
{
|
||||||
|
return $this->mergedInto;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMergedInto(?string $mergedInto): self
|
||||||
|
{
|
||||||
|
$this->mergedInto = $mergedInto;
|
||||||
|
$this->updateIndexed();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isIndexed(): bool
|
||||||
|
{
|
||||||
|
return $this->indexed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateIndexed(): void
|
||||||
|
{
|
||||||
|
$this->indexed = ContentFetchState::Ok === $this->fetchState
|
||||||
|
&& '' !== $this->bodyMd
|
||||||
|
&& null === $this->mergedInto
|
||||||
|
&& !$this->isHiddenFromSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TYPO3's own "not in the website search" flag, on products.
|
||||||
|
*/
|
||||||
|
public function isHiddenFromSearch(): bool
|
||||||
|
{
|
||||||
|
return 'product' === $this->type && false === ($this->data['searchable'] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\ContentSyncStateRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bookkeeping for the content feed sync. A single row.
|
||||||
|
*
|
||||||
|
* `manifestGeneratedAt` is the freshness figure: the `generatedAt` of the last manifest
|
||||||
|
* that was accepted. A run that stops early still advances it, because the deletions and
|
||||||
|
* the stale marks it is based on were applied.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: ContentSyncStateRepository::class)]
|
||||||
|
#[ORM\Table(name: 'content_sync_state')]
|
||||||
|
class ContentSyncState
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $manifestGeneratedAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $lastRunAt = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the last run ended, in a few words. Null after a run that completed.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
private ?string $lastError = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set from `Retry-After` when TYPO3 switched the feed off. Runs before it do nothing.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $pausedUntil = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getManifestGeneratedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->manifestGeneratedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setManifestGeneratedAt(?\DateTimeImmutable $manifestGeneratedAt): self
|
||||||
|
{
|
||||||
|
$this->manifestGeneratedAt = $manifestGeneratedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLastRunAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->lastRunAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLastRunAt(?\DateTimeImmutable $lastRunAt): self
|
||||||
|
{
|
||||||
|
$this->lastRunAt = $lastRunAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLastError(): ?string
|
||||||
|
{
|
||||||
|
return $this->lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLastError(?string $lastError): self
|
||||||
|
{
|
||||||
|
$this->lastError = $lastError;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPausedUntil(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->pausedUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPausedUntil(?\DateTimeImmutable $pausedUntil): self
|
||||||
|
{
|
||||||
|
$this->pausedUntil = $pausedUntil;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of the last fetch of one content feed item.
|
||||||
|
*
|
||||||
|
* `Empty` and `Failed` are remembered together with the manifest `modified` they were
|
||||||
|
* reached at, so a page that renders without text or keeps answering 404 is not refetched
|
||||||
|
* every run, only once TYPO3 reports a change or the weekly refresh comes round.
|
||||||
|
*/
|
||||||
|
enum ContentFetchState: string
|
||||||
|
{
|
||||||
|
/** Listed in the manifest, not fetched yet. */
|
||||||
|
case Pending = 'pending';
|
||||||
|
case Ok = 'ok';
|
||||||
|
/** Fetched, but converted to no text. Kept out of the index. */
|
||||||
|
case Empty = 'empty';
|
||||||
|
/** The page answered 404 or 5xx. Kept out of the index. */
|
||||||
|
case Failed = 'failed';
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Model\Chatbot;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filters of the search_trips tool, validated.
|
||||||
|
*/
|
||||||
|
final readonly class TripCriteria
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ?string $query = null,
|
||||||
|
public ?\DateTimeImmutable $dateFrom = null,
|
||||||
|
public ?\DateTimeImmutable $dateTo = null,
|
||||||
|
public ?string $country = null,
|
||||||
|
public ?string $region = null,
|
||||||
|
public ?string $concept = null,
|
||||||
|
public ?float $maxPrice = null,
|
||||||
|
public ?int $persons = null,
|
||||||
|
public ?bool $daytrip = null,
|
||||||
|
public ?string $site = null,
|
||||||
|
public int $limit = 5,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\ContentDocument;
|
||||||
|
use App\Enum\ContentFetchState;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\DBAL\ArrayParameterType;
|
||||||
|
use Doctrine\DBAL\ParameterType;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<ContentDocument>
|
||||||
|
*/
|
||||||
|
class ContentDocumentRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, ContentDocument::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ContentDocument> keyed by ContentDocument::getKey()
|
||||||
|
*/
|
||||||
|
public function findAllByKey(): array
|
||||||
|
{
|
||||||
|
$documents = [];
|
||||||
|
|
||||||
|
foreach ($this->findAll() as $document) {
|
||||||
|
$documents[$document->getKey()] = $document;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $documents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-text search over the indexed documents that can be linked to.
|
||||||
|
*
|
||||||
|
* DBAL rather than DQL: Doctrine has no MATCH ... AGAINST. The score is weighted by type
|
||||||
|
* so that, at similar relevance, what people book ranks above what they read.
|
||||||
|
*
|
||||||
|
* @param string $booleanQuery a MySQL boolean-mode query, built by ContentSearchQuery
|
||||||
|
* @param list<string> $types empty for all
|
||||||
|
* @param array<string, float> $typeWeights
|
||||||
|
*
|
||||||
|
* @return list<array{type: string, uid: int, title: string, url: string, site: ?string, breadcrumb: ?string, body_md: string, score: float}>
|
||||||
|
*/
|
||||||
|
public function searchFulltext(string $booleanQuery, array $types, ?string $site, array $typeWeights, int $limit): array
|
||||||
|
{
|
||||||
|
$weight = 'CASE type';
|
||||||
|
$params = ['query' => $booleanQuery, 'limit' => $limit];
|
||||||
|
$paramTypes = ['limit' => ParameterType::INTEGER];
|
||||||
|
|
||||||
|
foreach (array_keys($typeWeights) as $i => $type) {
|
||||||
|
$weight .= sprintf(' WHEN :wt%d THEN :wv%d', $i, $i);
|
||||||
|
$params['wt'.$i] = $type;
|
||||||
|
$params['wv'.$i] = $typeWeights[$type];
|
||||||
|
}
|
||||||
|
$weight .= ' ELSE 1 END';
|
||||||
|
|
||||||
|
$sql = 'SELECT type, uid, title, url, site, breadcrumb, body_md,'
|
||||||
|
.' MATCH(title, breadcrumb, body_md) AGAINST(:query IN BOOLEAN MODE) * '.$weight.' AS score'
|
||||||
|
.' FROM content_document'
|
||||||
|
.' WHERE indexed = 1 AND url IS NOT NULL'
|
||||||
|
.' AND MATCH(title, breadcrumb, body_md) AGAINST(:query IN BOOLEAN MODE)';
|
||||||
|
|
||||||
|
if ([] !== $types) {
|
||||||
|
$sql .= ' AND type IN (:types)';
|
||||||
|
$params['types'] = $types;
|
||||||
|
$paramTypes['types'] = ArrayParameterType::STRING;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $site) {
|
||||||
|
$sql .= ' AND site = :site';
|
||||||
|
$params['site'] = $site;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY score DESC, uid ASC LIMIT :limit';
|
||||||
|
|
||||||
|
$rows = $this->getEntityManager()->getConnection()->fetchAllAssociative($sql, $params, $paramTypes);
|
||||||
|
|
||||||
|
return array_map(static fn (array $row): array => [
|
||||||
|
'type' => (string) $row['type'],
|
||||||
|
'uid' => (int) $row['uid'],
|
||||||
|
'title' => (string) $row['title'],
|
||||||
|
'url' => (string) $row['url'],
|
||||||
|
'site' => null === $row['site'] ? null : (string) $row['site'],
|
||||||
|
'breadcrumb' => null === $row['breadcrumb'] ? null : (string) $row['breadcrumb'],
|
||||||
|
'body_md' => (string) $row['body_md'],
|
||||||
|
'score' => (float) $row['score'],
|
||||||
|
], $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `(type, uid)` of every fetched record whose text matches, for resolving free text
|
||||||
|
* in the trip search. Unlike searchFulltext(), records without a URL count: a hotel with
|
||||||
|
* no page of its own still leads to the products it belongs to.
|
||||||
|
*
|
||||||
|
* @param list<string> $types
|
||||||
|
*
|
||||||
|
* @return list<array{type: string, uid: int}>
|
||||||
|
*/
|
||||||
|
public function matchRecords(string $booleanQuery, array $types, string $language): array
|
||||||
|
{
|
||||||
|
$rows = $this->getEntityManager()->getConnection()->fetchAllAssociative(
|
||||||
|
'SELECT type, uid FROM content_document'
|
||||||
|
.' WHERE fetch_state = :ok AND language = :language AND type IN (:types)'
|
||||||
|
.' AND MATCH(title, breadcrumb, body_md) AGAINST(:query IN BOOLEAN MODE)',
|
||||||
|
['ok' => ContentFetchState::Ok->value, 'language' => $language, 'types' => $types, 'query' => $booleanQuery],
|
||||||
|
['types' => ArrayParameterType::STRING],
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map(static fn (array $row): array => ['type' => (string) $row['type'], 'uid' => (int) $row['uid']], $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetched records of the given types in one language, keyed by `type:uid`.
|
||||||
|
*
|
||||||
|
* @param list<string> $types
|
||||||
|
*
|
||||||
|
* @return array<string, ContentDocument>
|
||||||
|
*/
|
||||||
|
public function findRecords(array $types, string $language): array
|
||||||
|
{
|
||||||
|
/** @var list<ContentDocument> $documents */
|
||||||
|
$documents = $this->createQueryBuilder('d')
|
||||||
|
->where('d.type IN (:types)')
|
||||||
|
->andWhere('d.language = :language')
|
||||||
|
->andWhere('d.fetchState = :ok')
|
||||||
|
->setParameter('types', $types)
|
||||||
|
->setParameter('language', $language)
|
||||||
|
->setParameter('ok', ContentFetchState::Ok)
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
|
||||||
|
$indexed = [];
|
||||||
|
|
||||||
|
foreach ($documents as $document) {
|
||||||
|
$indexed[$document->getRecordRef()] = $document;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $indexed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The indexed document published at a URL. Trailing slashes are not significant.
|
||||||
|
*/
|
||||||
|
public function findOneIndexedByUrl(string $url): ?ContentDocument
|
||||||
|
{
|
||||||
|
$trimmed = rtrim($url, '/');
|
||||||
|
|
||||||
|
/** @var list<ContentDocument> $documents */
|
||||||
|
$documents = $this->createQueryBuilder('d')
|
||||||
|
->where('d.url IN (:urls)')
|
||||||
|
->andWhere('d.indexed = true')
|
||||||
|
->setParameter('urls', [$trimmed, $trimmed.'/'])
|
||||||
|
->orderBy('d.id', 'ASC')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
|
||||||
|
return $documents[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\ContentSyncState;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<ContentSyncState>
|
||||||
|
*/
|
||||||
|
class ContentSyncStateRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, ContentSyncState::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single state row, created unpersisted on first use.
|
||||||
|
*/
|
||||||
|
public function get(): ContentSyncState
|
||||||
|
{
|
||||||
|
return $this->findOneBy([], ['id' => 'ASC']) ?? new ContentSyncState();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
|
||||||
|
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the static bearer token n8n sends to the chatbot MCP endpoint.
|
||||||
|
*
|
||||||
|
* The data behind it is public, but the endpoint must not become a free LLM-tooling API
|
||||||
|
* for whoever finds it. A static token rather than the OAuth2 client-credentials grant of
|
||||||
|
* /api: n8n's MCP Client Tool node sends a fixed header and cannot refresh a 10-minute token.
|
||||||
|
* An unconfigured token refuses every request.
|
||||||
|
*/
|
||||||
|
final class McpAccessTokenHandler implements AccessTokenHandlerInterface
|
||||||
|
{
|
||||||
|
public const USER = 'n8n';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
#[\SensitiveParameter]
|
||||||
|
private readonly string $token,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
|
||||||
|
{
|
||||||
|
if ('' === $this->token || !hash_equals($this->token, $accessToken)) {
|
||||||
|
throw new BadCredentialsException('Invalid MCP token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UserBadge(self::USER);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,18 @@
|
|||||||
"config/packages/nyholm_psr7.yaml"
|
"config/packages/nyholm_psr7.yaml"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"php-http/discovery": {
|
||||||
|
"version": "1.20",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "1.18",
|
||||||
|
"ref": "f45b5dd173a27873ab19f5e3180b2f661c21de02"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/http_discovery.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
"phpstan/phpstan": {
|
"phpstan/phpstan": {
|
||||||
"version": "2.1",
|
"version": "2.1",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
@@ -241,6 +253,9 @@
|
|||||||
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
|
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"symfony/mcp-bundle": {
|
||||||
|
"version": "v0.14.1"
|
||||||
|
},
|
||||||
"symfony/messenger": {
|
"symfony/messenger": {
|
||||||
"version": "6.4",
|
"version": "6.4",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Chatbot\Search;
|
||||||
|
|
||||||
|
use App\Chatbot\Search\ContentSearchQuery;
|
||||||
|
use App\Chatbot\Search\Excerpt;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ContentSearchQueryTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testDropsGermanStopwordsShortTokensAndOperators(): void
|
||||||
|
{
|
||||||
|
self::assertSame(
|
||||||
|
'skipass* val* thorens* inklusive*',
|
||||||
|
ContentSearchQuery::booleanQuery('Ist im Skipass bei "Val Thorens" alles +inklusive? Ja!'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTermsAreUniqueAndLowerCased(): void
|
||||||
|
{
|
||||||
|
self::assertSame(['gepäck', 'snowboard'], ContentSearchQuery::terms('Gepäck GEPÄCK Snowboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNothingSearchableLeftIsNull(): void
|
||||||
|
{
|
||||||
|
self::assertNull(ContentSearchQuery::booleanQuery('Was ist das?'));
|
||||||
|
self::assertNull(ContentSearchQuery::booleanQuery('***'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExcerptIsTheWholeTextWhenShort(): void
|
||||||
|
{
|
||||||
|
self::assertSame('Kurzer Text', Excerpt::around("Kurzer\n\nText", ['text']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExcerptCentresOnTheFirstMatch(): void
|
||||||
|
{
|
||||||
|
$text = str_repeat('Füllwort ', 200).'Der Skipass ist inklusive. '.str_repeat('Nachwort ', 200);
|
||||||
|
$excerpt = Excerpt::around($text, ['skipass']);
|
||||||
|
|
||||||
|
self::assertStringContainsString('Der Skipass ist inklusive.', $excerpt);
|
||||||
|
self::assertStringStartsWith('…', $excerpt);
|
||||||
|
self::assertStringEndsWith('…', $excerpt);
|
||||||
|
self::assertLessThanOrEqual(Excerpt::LENGTH + 2, mb_strlen($excerpt));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Chatbot;
|
||||||
|
|
||||||
|
use App\Chatbot\TripCatalog;
|
||||||
|
use App\Entity\ContentDocument;
|
||||||
|
use App\Enum\ContentFetchState;
|
||||||
|
use App\Model\Chatbot\TripCriteria;
|
||||||
|
use App\Model\Export;
|
||||||
|
use App\Repository\ContentDocumentRepository;
|
||||||
|
use App\Service\ProductExportBuilder;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\Clock\MockClock;
|
||||||
|
|
||||||
|
class TripCatalogTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testSearchReturnsUpcomingDatesWithTheLowestBookablePrice(): void
|
||||||
|
{
|
||||||
|
$result = $this->catalog([
|
||||||
|
$this->row('VTH', 1, '2026-09-01', 'HOT1', [$this->room(100)]),
|
||||||
|
$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(500), $this->room(450, status: 'Anfrage'), $this->room(99, status: 'Buchungsstop')]),
|
||||||
|
$this->row('VTH', 3, '2027-01-09', 'HOT1', [$this->room(520, status: 'Anfrage')]),
|
||||||
|
])->search(new TripCriteria());
|
||||||
|
|
||||||
|
self::assertCount(1, $result['results']);
|
||||||
|
$trip = $result['results'][0];
|
||||||
|
self::assertSame('VTH', $trip['code']);
|
||||||
|
self::assertSame('Val Thorens Apartments', $trip['name']);
|
||||||
|
self::assertSame('https://www.ep-reisen.de/val-thorens/', $trip['url']);
|
||||||
|
self::assertSame(['country' => 'Frankreich'], $trip['destination']);
|
||||||
|
self::assertSame(['Hotel Eins'], $trip['hotels']);
|
||||||
|
self::assertSame([
|
||||||
|
['dateFrom' => '2026-12-05', 'dateTo' => '2026-12-12', 'priceFrom' => 450.0, 'availability' => 'frei'],
|
||||||
|
['dateFrom' => '2027-01-09', 'dateTo' => '2027-01-16', 'priceFrom' => 520.0, 'availability' => 'auf Anfrage'],
|
||||||
|
], $trip['nextDates']);
|
||||||
|
self::assertSame('2026-10-08T06:00:00+00:00', $result['dataAsOf']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPersonsCountsPlacesAcrossBookableRoomsOnly(): void
|
||||||
|
{
|
||||||
|
$rows = [
|
||||||
|
// 2 rooms x 2 pax + 1 x 1 pax = 5 places; the stopped room's 10 do not count.
|
||||||
|
$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(400, available: 2, maxPax: 2), $this->room(300, available: 1, maxPax: 1), $this->room(200, available: 10, maxPax: 1, status: 'Buchungsstop')]),
|
||||||
|
];
|
||||||
|
|
||||||
|
self::assertCount(1, $this->catalog($rows)->search(new TripCriteria(persons: 5))['results']);
|
||||||
|
self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(persons: 6))['results']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDatePeriodMatchesOnOverlapAndMaxPriceOnTheLowestPrice(): void
|
||||||
|
{
|
||||||
|
$rows = [$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(450), $this->room(700)])];
|
||||||
|
|
||||||
|
self::assertCount(1, $this->catalog($rows)->search(new TripCriteria(dateFrom: new \DateTimeImmutable('2026-12-12'), dateTo: new \DateTimeImmutable('2026-12-31')))['results']);
|
||||||
|
self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(dateFrom: new \DateTimeImmutable('2026-12-13')))['results']);
|
||||||
|
self::assertCount(1, $this->catalog($rows)->search(new TripCriteria(maxPrice: 450))['results']);
|
||||||
|
self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(maxPrice: 449.99))['results']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCountryAndConceptAreResolvedThroughTheContentRecords(): void
|
||||||
|
{
|
||||||
|
$rows = [$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(450)])];
|
||||||
|
|
||||||
|
self::assertCount(1, $this->catalog($rows)->search(new TripCriteria(country: 'frankreich', concept: 'ski'))['results']);
|
||||||
|
self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(country: 'Österreich'))['results']);
|
||||||
|
self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(concept: 'party'))['results']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetJoinsHotelsOnTheirAliasesAndPreferTheOwner(): void
|
||||||
|
{
|
||||||
|
$trip = $this->catalog([
|
||||||
|
$this->row('VTH', 2, '2026-12-05', 'HOT1ALIAS', [$this->room(450)]),
|
||||||
|
$this->row('VTH', 2, '2026-12-05', 'HOT2', [$this->room(380, status: 'Buchungsstop')]),
|
||||||
|
])->get('vth', 'HOT1');
|
||||||
|
|
||||||
|
self::assertNotNull($trip);
|
||||||
|
self::assertSame(['Hotel Eins'], array_column($trip['hotels'], 'name'));
|
||||||
|
self::assertCount(1, $trip['dates']);
|
||||||
|
self::assertSame('Hotel Eins', $trip['dates'][0]['hotels'][0]['hotel']);
|
||||||
|
self::assertSame([['room' => 'Zimmer', 'board' => 'HP', 'nights' => 7, 'price' => 450, 'availability' => 'frei']], $trip['dates'][0]['hotels'][0]['rooms']);
|
||||||
|
self::assertSame([['city' => 'Frankfurt', 'place' => 'Flughafen', 'departure' => '2026-12-04 23:30', 'surcharge' => 5.9]], $trip['dates'][0]['pickups']);
|
||||||
|
self::assertArrayNotHasKey('guide', $trip['dates'][0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testProductsHiddenFromTheWebsiteSearchAreNotOfferedButCanBeNamed(): void
|
||||||
|
{
|
||||||
|
$rows = [$this->row('GRP', 2, '2026-12-05', 'HOT1', [$this->room(300)])];
|
||||||
|
|
||||||
|
self::assertSame([], $this->catalog($rows)->search(new TripCriteria())['results']);
|
||||||
|
self::assertSame('GRP', $this->catalog($rows)->get('GRP')['code'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetIsNullForACodeWithoutAPublishedProduct(): void
|
||||||
|
{
|
||||||
|
self::assertNull($this->catalog([$this->row('XYZ', 2, '2026-12-05', 'HOT1', [$this->room(1)])])->get('XYZ'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
private function catalog(array $rows): TripCatalog
|
||||||
|
{
|
||||||
|
$export = $this->createStub(ProductExportBuilder::class);
|
||||||
|
$export->method('build')->willReturn(new Export($rows, 0, 0, new \DateTimeImmutable('2026-10-08 09:00:00+00:00'), new \DateTimeImmutable('2026-10-08 06:00:00+00:00')));
|
||||||
|
|
||||||
|
$records = [];
|
||||||
|
foreach ([
|
||||||
|
$this->record('product', 1, 'Val Thorens Apartments', 'https://www.ep-reisen.de/val-thorens/', ['code' => 'VTH', 'countryUid' => 2, 'conceptUid' => 7, 'hotelUids' => [10], 'daytrip' => false, 'bookable' => true]),
|
||||||
|
$this->record('product', 2, 'Gruppenreise Uni X', 'https://www.ep-reisen.de/gruppe/', ['code' => 'GRP', 'countryUid' => 2, 'searchable' => false, 'bookable' => true]),
|
||||||
|
$this->record('hotel', 10, 'Hotel Eins', null, ['code' => 'HOT1', 'codes' => ['HOT1', 'HOT1ALIAS', 'SHARED'], 'facts' => ['wifi' => true, 'sauna' => null]]),
|
||||||
|
$this->record('hotel', 11, 'Hotel Zwei', null, ['code' => 'SHARED', 'codes' => ['HOT2']]),
|
||||||
|
$this->record('country', 2, 'Frankreich', null, ['code' => 'FR']),
|
||||||
|
$this->record('concept', 7, 'Skiurlaub ohne Programm', null, ['code' => 'ski']),
|
||||||
|
] as $record) {
|
||||||
|
$records[$record->getRecordRef()] = $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
$repository = $this->createStub(ContentDocumentRepository::class);
|
||||||
|
$repository->method('findRecords')->willReturn($records);
|
||||||
|
$repository->method('matchRecords')->willReturn([]);
|
||||||
|
|
||||||
|
return new TripCatalog($export, $repository, new MockClock('2026-10-08 10:00:00'), new NullLogger());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private function record(string $type, int $uid, string $title, ?string $url, array $data): ContentDocument
|
||||||
|
{
|
||||||
|
return (new ContentDocument($type, $uid, 'de', new \DateTimeImmutable()))
|
||||||
|
->setTitle($title)
|
||||||
|
->setUrl($url)
|
||||||
|
->setSite(null === $url ? null : 'ep-reisen')
|
||||||
|
->setData($data + ['uid' => $uid])
|
||||||
|
->setBodyMd('# '.$title)
|
||||||
|
->markFetched(ContentFetchState::Ok, new \DateTimeImmutable());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rooms
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function row(string $code, int $dateId, string $from, string $hotelCode, array $rooms): array
|
||||||
|
{
|
||||||
|
$dateFrom = new \DateTimeImmutable($from);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'product' => ['code' => $code, 'busProId' => 1],
|
||||||
|
'date' => ['code' => $code.$dateId, 'busProId' => $dateId, 'from' => $from, 'to' => $dateFrom->modify('+7 days')->format('Y-m-d'), 'type' => 'F'],
|
||||||
|
'hotel' => ['code' => $hotelCode, 'busProId' => crc32($hotelCode)],
|
||||||
|
'rooms' => $rooms,
|
||||||
|
'pickups' => [
|
||||||
|
['busProId' => 4, 'code' => 'FRA', 'city' => 'Frankfurt', 'street' => 'Flughafen', 'price' => 5.9, 'datetime' => $dateFrom->modify('-1 day')->format('Y-m-d').'T23:30:00'],
|
||||||
|
['busProId' => 0, 'code' => null, 'city' => null, 'street' => null, 'price' => null, 'datetime' => null],
|
||||||
|
],
|
||||||
|
'guide' => ['name' => 'Anna', 'phone' => '0170'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function room(int $price, int $available = 5, int $maxPax = 2, string $status = 'Frei'): array
|
||||||
|
{
|
||||||
|
return ['code' => 'DZ', 'label' => 'Zimmer', 'minPax' => 1, 'maxPax' => $maxPax, 'nights' => 7, 'price' => $price, 'available' => $available, 'status' => $status, 'boardCode' => 'HP'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\ContentFeed;
|
||||||
|
|
||||||
|
use App\ContentFeed\ContentFeedClient;
|
||||||
|
use App\ContentFeed\Exception\ContentFeedStoppedException;
|
||||||
|
use App\ContentFeed\Exception\ContentFeedUnavailableException;
|
||||||
|
use App\ContentFeed\Exception\ContentRequestFailedException;
|
||||||
|
use App\ContentFeed\Exception\PagePhaseEndedException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\Clock\MockClock;
|
||||||
|
use Symfony\Component\HttpClient\Exception\TransportException;
|
||||||
|
use Symfony\Component\HttpClient\MockHttpClient;
|
||||||
|
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The load rules are a contract with the maintainers of the TYPO3 site, so each one is
|
||||||
|
* pinned here.
|
||||||
|
*/
|
||||||
|
class ContentFeedClientTest extends TestCase
|
||||||
|
{
|
||||||
|
private const BASE = 'https://www.ep-reisen.de/';
|
||||||
|
|
||||||
|
private MockClock $clock;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->clock = new MockClock('2026-10-08 10:00:00');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testManifestIsParsedAndSendsTheApiKey(): void
|
||||||
|
{
|
||||||
|
$requests = [];
|
||||||
|
$client = $this->client([
|
||||||
|
function (string $method, string $url, array $options) use (&$requests): MockResponse {
|
||||||
|
$requests[] = [$url, $options['normalized_headers']['x-api-key'][0] ?? null];
|
||||||
|
|
||||||
|
return $this->manifestResponse();
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
$manifest = $client->manifest();
|
||||||
|
|
||||||
|
self::assertSame([['https://www.ep-reisen.de/api/content/manifest', 'X-Api-Key: secret']], $requests);
|
||||||
|
self::assertCount(2, $manifest->items);
|
||||||
|
self::assertSame(['www.ep-reisen.de', 'snowzone.net'], $manifest->siteHosts);
|
||||||
|
self::assertSame('2026-10-08 08:15:00 UTC', $manifest->generatedAt->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s T'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{MockResponse}>
|
||||||
|
*/
|
||||||
|
public static function unusableManifests(): iterable
|
||||||
|
{
|
||||||
|
yield 'server error' => [new MockResponse('', ['http_code' => 500])];
|
||||||
|
yield 'forbidden' => [new MockResponse('', ['http_code' => 403])];
|
||||||
|
yield 'invalid json' => [new MockResponse('{"items":')];
|
||||||
|
yield 'empty items' => [new MockResponse('{"generatedAt":"2026-10-08T10:15:00+02:00","sites":[],"items":[]}')];
|
||||||
|
yield 'malformed item' => [new MockResponse('{"generatedAt":"2026-10-08T10:15:00+02:00","sites":[],"items":[{"type":"page"}]}')];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider unusableManifests
|
||||||
|
*/
|
||||||
|
#[\PHPUnit\Framework\Attributes\DataProvider('unusableManifests')]
|
||||||
|
public function testAnUnusableManifestStopsTheRun(MockResponse $response): void
|
||||||
|
{
|
||||||
|
$this->expectException(ContentFeedStoppedException::class);
|
||||||
|
|
||||||
|
$this->client([$response])->manifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMissingApiKeyStopsWithoutARequest(): void
|
||||||
|
{
|
||||||
|
$this->expectException(ContentFeedStoppedException::class);
|
||||||
|
$this->expectExceptionMessage('APP_CMS_API_KEY');
|
||||||
|
|
||||||
|
(new ContentFeedClient(new MockHttpClient([]), $this->clock, new NullLogger(), self::BASE, ''))->manifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test503EndsTheRunWithRetryAfter(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->client([new MockResponse('', ['http_code' => 503, 'response_headers' => ['Retry-After' => '3600']])])->manifest();
|
||||||
|
self::fail('Expected the run to stop');
|
||||||
|
} catch (ContentFeedUnavailableException $e) {
|
||||||
|
self::assertSame($this->clock->now()->getTimestamp() + 3600, $e->retryAfter?->getTimestamp());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRecordsAreKeyedByUidFromTheEnvelope(): void
|
||||||
|
{
|
||||||
|
$client = $this->client([new MockResponse('{"type":"product","items":[{"uid":412,"name":"A"},{"uid":413,"name":"B"}]}')]);
|
||||||
|
|
||||||
|
$records = $client->records('product', [412, 413, 414]);
|
||||||
|
|
||||||
|
self::assertSame([412, 413], array_keys($records));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testARecordsResponseWithoutTheEnvelopeFailsTheBatchInsteadOfDeleting(): void
|
||||||
|
{
|
||||||
|
$this->expectException(ContentRequestFailedException::class);
|
||||||
|
|
||||||
|
$this->client([new MockResponse('[{"uid":412}]')])->records('product', [412]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testThreeConsecutiveServerErrorsOrTimeoutsStopTheRun(): void
|
||||||
|
{
|
||||||
|
$client = $this->client([
|
||||||
|
new MockResponse('', ['http_code' => 500]),
|
||||||
|
new MockResponse([new TransportException('timeout')]),
|
||||||
|
new MockResponse('', ['http_code' => 502]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ([1, 2] as $attempt) {
|
||||||
|
try {
|
||||||
|
$client->records('product', [$attempt]);
|
||||||
|
self::fail('Expected the batch to fail');
|
||||||
|
} catch (ContentRequestFailedException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->expectException(ContentFeedStoppedException::class);
|
||||||
|
$client->records('product', [3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASuccessResetsTheFailureCount(): void
|
||||||
|
{
|
||||||
|
$client = $this->client([
|
||||||
|
new MockResponse('', ['http_code' => 500]),
|
||||||
|
new MockResponse('', ['http_code' => 500]),
|
||||||
|
new MockResponse('{"type":"product","items":[]}'),
|
||||||
|
new MockResponse('', ['http_code' => 500]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$failures = 0;
|
||||||
|
foreach ([1, 2, 3, 4] as $uid) {
|
||||||
|
try {
|
||||||
|
$client->records('product', [$uid]);
|
||||||
|
} catch (ContentRequestFailedException) {
|
||||||
|
++$failures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertSame(3, $failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASinglePageErrorIsAFailureOfThatPageOnly(): void
|
||||||
|
{
|
||||||
|
$client = $this->clientWithManifest([
|
||||||
|
new MockResponse('', ['http_code' => 404]),
|
||||||
|
new MockResponse('', ['http_code' => 500]),
|
||||||
|
new MockResponse('<article><h1>A</h1></article>'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(404, $client->page('https://www.ep-reisen.de/a?type=1730')->failedStatus);
|
||||||
|
self::assertSame(500, $client->page('https://www.ep-reisen.de/b?type=1730')->failedStatus);
|
||||||
|
self::assertSame('<article><h1>A</h1></article>', $client->page('https://www.ep-reisen.de/c?type=1730')->html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPageTimeoutIsTransient(): void
|
||||||
|
{
|
||||||
|
$client = $this->clientWithManifest([new MockResponse([new TransportException('timeout')])]);
|
||||||
|
|
||||||
|
self::assertTrue($client->page('https://www.ep-reisen.de/a?type=1730')->transient);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPagesAreFetchedAtMostOncePerSecond(): void
|
||||||
|
{
|
||||||
|
$client = $this->clientWithManifest([new MockResponse('<p>a</p>'), new MockResponse('<p>b</p>'), new MockResponse('<p>c</p>')]);
|
||||||
|
$start = $this->clock->now();
|
||||||
|
|
||||||
|
$client->page('https://www.ep-reisen.de/a?type=1730');
|
||||||
|
$client->page('https://www.ep-reisen.de/b?type=1730');
|
||||||
|
$client->page('https://www.ep-reisen.de/c?type=1730');
|
||||||
|
|
||||||
|
self::assertSame(2, $this->clock->now()->getTimestamp() - $start->getTimestamp());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASlowResponseDelaysTheNextRequestByFiveSeconds(): void
|
||||||
|
{
|
||||||
|
$client = $this->clientWithManifest([new MockResponse('<p>a</p>', ['total_time' => 2.5]), new MockResponse('<p>b</p>')]);
|
||||||
|
$start = $this->clock->now();
|
||||||
|
|
||||||
|
$client->page('https://www.ep-reisen.de/a?type=1730');
|
||||||
|
$client->page('https://www.ep-reisen.de/b?type=1730');
|
||||||
|
|
||||||
|
self::assertSame(5, $this->clock->now()->getTimestamp() - $start->getTimestamp());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testThreeSlowPagesInARowEndThePagePhase(): void
|
||||||
|
{
|
||||||
|
$client = $this->clientWithManifest([
|
||||||
|
new MockResponse('<p>a</p>', ['total_time' => 3.0]),
|
||||||
|
new MockResponse('<p>b</p>', ['total_time' => 3.0]),
|
||||||
|
new MockResponse('<p>c</p>', ['total_time' => 3.0]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$client->page('https://www.ep-reisen.de/a?type=1730');
|
||||||
|
$client->page('https://www.ep-reisen.de/b?type=1730');
|
||||||
|
|
||||||
|
$this->expectException(PagePhaseEndedException::class);
|
||||||
|
$client->page('https://www.ep-reisen.de/c?type=1730');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheKeyIsNeverSentToAHostOutsideTheManifestSites(): void
|
||||||
|
{
|
||||||
|
$client = $this->clientWithManifest([]);
|
||||||
|
|
||||||
|
$result = $client->page('https://evil.example/a?type=1730');
|
||||||
|
|
||||||
|
self::assertFalse($result->isOk());
|
||||||
|
self::assertFalse($result->transient);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<MockResponse|callable> $responses
|
||||||
|
*/
|
||||||
|
private function client(array $responses): ContentFeedClient
|
||||||
|
{
|
||||||
|
$client = new ContentFeedClient(new MockHttpClient($responses, self::BASE), $this->clock, new NullLogger(), self::BASE, 'secret');
|
||||||
|
$client->startRun();
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A client that has read the manifest, so it knows the sites' hosts.
|
||||||
|
*
|
||||||
|
* @param list<MockResponse> $responses
|
||||||
|
*/
|
||||||
|
private function clientWithManifest(array $responses): ContentFeedClient
|
||||||
|
{
|
||||||
|
$client = $this->client(array_merge([$this->manifestResponse()], $responses));
|
||||||
|
$client->manifest();
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function manifestResponse(): MockResponse
|
||||||
|
{
|
||||||
|
return new MockResponse(json_encode([
|
||||||
|
'generatedAt' => '2026-10-08T10:15:00+02:00',
|
||||||
|
'sites' => [
|
||||||
|
['identifier' => 'ep-reisen', 'base' => 'https://www.ep-reisen.de/'],
|
||||||
|
['identifier' => 'snowzone', 'base' => 'https://snowzone.net/'],
|
||||||
|
],
|
||||||
|
'items' => [
|
||||||
|
['type' => 'page', 'uid' => 1, 'language' => 'de', 'site' => 'ep-reisen', 'url' => 'https://www.ep-reisen.de/a', 'contentUrl' => 'https://www.ep-reisen.de/a?type=1730', 'modified' => '2026-09-30T14:02:11+02:00'],
|
||||||
|
['type' => 'product', 'uid' => 412, 'language' => 'de', 'site' => 'ep-reisen', 'url' => null, 'modified' => '2026-09-12T09:40:00+02:00'],
|
||||||
|
],
|
||||||
|
], \JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\ContentFeed;
|
||||||
|
|
||||||
|
use App\Chatbot\TripCatalog;
|
||||||
|
use App\ContentFeed\ContentFeedClient;
|
||||||
|
use App\ContentFeed\ContentSyncer;
|
||||||
|
use App\ContentFeed\DocumentBodyBuilder;
|
||||||
|
use App\ContentFeed\Exception\ContentFeedStoppedException;
|
||||||
|
use App\ContentFeed\HtmlToMarkdownConverter;
|
||||||
|
use App\Entity\ContentDocument;
|
||||||
|
use App\Entity\ContentSyncState;
|
||||||
|
use App\Enum\ContentFetchState;
|
||||||
|
use App\Model\ContentFeed\ContentManifest;
|
||||||
|
use App\Model\ContentFeed\ManifestItem;
|
||||||
|
use App\Model\ContentFeed\PageFetchResult;
|
||||||
|
use App\Repository\ContentDocumentRepository;
|
||||||
|
use App\Repository\ContentSyncStateRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\MockObject\Stub;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\Clock\MockClock;
|
||||||
|
|
||||||
|
class ContentSyncerTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var array<string, ContentDocument> what the fake database holds */
|
||||||
|
private array $stored = [];
|
||||||
|
|
||||||
|
private ContentSyncState $state;
|
||||||
|
|
||||||
|
private ContentFeedClient&Stub $client;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->state = new ContentSyncState();
|
||||||
|
$this->client = $this->createStub(ContentFeedClient::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAFailedManifestChangesNothing(): void
|
||||||
|
{
|
||||||
|
$this->stored = ['page:1:de' => $this->document('page', 1)];
|
||||||
|
$this->client->method('manifest')->willThrowException(new ContentFeedStoppedException('Manifest has no items'));
|
||||||
|
|
||||||
|
$report = $this->syncer()->run();
|
||||||
|
|
||||||
|
self::assertSame('Manifest has no items', $report->stoppedBy);
|
||||||
|
self::assertArrayHasKey('page:1:de', $this->stored);
|
||||||
|
self::assertNull($this->state->getManifestGeneratedAt());
|
||||||
|
self::assertSame('Manifest has no items', $this->state->getLastError());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItemsMissingFromTheManifestAreDeleted(): void
|
||||||
|
{
|
||||||
|
$this->stored = ['page:1:de' => $this->document('page', 1), 'page:2:de' => $this->document('page', 2)];
|
||||||
|
$this->client->method('manifest')->willReturn($this->manifest([$this->pageItem(1)]));
|
||||||
|
$this->client->method('page')->willReturn(PageFetchResult::ok('<p>Eins</p>'));
|
||||||
|
|
||||||
|
$report = $this->syncer()->run();
|
||||||
|
|
||||||
|
self::assertSame(1, $report->deleted);
|
||||||
|
self::assertSame(['page:1:de'], array_keys($this->stored));
|
||||||
|
self::assertEquals(new \DateTimeImmutable('2026-10-08 08:15:00'), $this->state->getManifestGeneratedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRecordsAbsentFromTheResponseAreDeleted(): void
|
||||||
|
{
|
||||||
|
$this->client->method('manifest')->willReturn($this->manifest([$this->recordItem('product', 412), $this->recordItem('product', 413)]));
|
||||||
|
$this->client->method('records')->willReturn([412 => ['uid' => 412, 'name' => 'Val Thorens', 'code' => 'VTH', 'text' => ['teaser' => '<p>Ski</p>']]]);
|
||||||
|
|
||||||
|
$report = $this->syncer()->run();
|
||||||
|
|
||||||
|
self::assertSame(1, $report->records);
|
||||||
|
self::assertSame(['product:412:de'], array_keys($this->stored));
|
||||||
|
self::assertSame("# Val Thorens\n\nSki", $this->stored['product:412:de']->getBodyMd());
|
||||||
|
self::assertTrue($this->stored['product:412:de']->isIndexed());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testADetailPageIsMergedIntoItsRecordAndNotIndexedItself(): void
|
||||||
|
{
|
||||||
|
$this->client->method('manifest')->willReturn($this->manifest([
|
||||||
|
$this->recordItem('product', 412, url: null),
|
||||||
|
$this->pageItem(7, detailOf: [['type' => 'product', 'uid' => 412]]),
|
||||||
|
]));
|
||||||
|
$this->client->method('records')->willReturn([412 => ['uid' => 412, 'name' => 'Val Thorens', 'text' => []]]);
|
||||||
|
$this->client->method('page')->willReturn(PageFetchResult::ok('<article><h2>Anreise</h2><p>Mit dem Bus</p></article>'));
|
||||||
|
|
||||||
|
$this->syncer()->run();
|
||||||
|
|
||||||
|
$record = $this->stored['product:412:de'];
|
||||||
|
$page = $this->stored['page:7:de'];
|
||||||
|
self::assertSame("# Val Thorens\n\n## Anreise\n\nMit dem Bus", $record->getBodyMd());
|
||||||
|
self::assertSame('product:412', $page->getMergedInto());
|
||||||
|
self::assertFalse($page->isIndexed());
|
||||||
|
self::assertSame('https://www.ep-reisen.de/page-7', $record->getUrl(), 'a record without a page of its own is linkable through its detail page');
|
||||||
|
self::assertSame('ep-reisen', $record->getSite());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailedAndEmptyPagesAreRememberedUntilTheyChange(): void
|
||||||
|
{
|
||||||
|
$this->client->method('manifest')->willReturn($this->manifest([$this->pageItem(1), $this->pageItem(2)]));
|
||||||
|
$this->client->method('page')->willReturnOnConsecutiveCalls(PageFetchResult::failed(404), PageFetchResult::ok('<article><img src="a.jpg"></article>'));
|
||||||
|
|
||||||
|
$this->syncer()->run();
|
||||||
|
|
||||||
|
self::assertSame(ContentFetchState::Failed, $this->stored['page:1:de']->getFetchState());
|
||||||
|
self::assertSame(ContentFetchState::Empty, $this->stored['page:2:de']->getFetchState());
|
||||||
|
self::assertFalse($this->stored['page:1:de']->isStale());
|
||||||
|
self::assertFalse($this->stored['page:2:de']->isStale());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testATimedOutPageIsRetriedNextRun(): void
|
||||||
|
{
|
||||||
|
$this->client->method('manifest')->willReturn($this->manifest([$this->pageItem(1)]));
|
||||||
|
$this->client->method('page')->willReturn(PageFetchResult::transient());
|
||||||
|
|
||||||
|
$report = $this->syncer()->run();
|
||||||
|
|
||||||
|
self::assertSame(1, $report->failed);
|
||||||
|
self::assertTrue($this->stored['page:1:de']->isStale());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOutsideTheNightWindowAtMostFiftyPagesAreFetched(): void
|
||||||
|
{
|
||||||
|
$items = array_map(fn (int $uid): ManifestItem => $this->pageItem($uid), range(1, 60));
|
||||||
|
$this->client->method('manifest')->willReturn($this->manifest($items));
|
||||||
|
$this->client->method('page')->willReturn(PageFetchResult::ok('<p>Text</p>'));
|
||||||
|
|
||||||
|
$report = $this->syncer('2026-10-08 12:00:00')->run();
|
||||||
|
self::assertSame(50, $report->pages);
|
||||||
|
self::assertSame(10, $report->deferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInsideTheNightWindowEveryDuePageIsFetched(): void
|
||||||
|
{
|
||||||
|
$items = array_map(fn (int $uid): ManifestItem => $this->pageItem($uid), range(1, 60));
|
||||||
|
$this->client->method('manifest')->willReturn($this->manifest($items));
|
||||||
|
$this->client->method('page')->willReturn(PageFetchResult::ok('<p>Text</p>'));
|
||||||
|
|
||||||
|
// 02:00 in Berlin.
|
||||||
|
$report = $this->syncer('2026-10-08 00:00:00')->run();
|
||||||
|
|
||||||
|
self::assertSame(60, $report->pages);
|
||||||
|
self::assertSame(0, $report->deferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPausedFeedIsNotCalled(): void
|
||||||
|
{
|
||||||
|
$this->state->setPausedUntil(new \DateTimeImmutable('2026-10-08 13:00:00', new \DateTimeZone('UTC')));
|
||||||
|
$client = $this->createMock(ContentFeedClient::class);
|
||||||
|
$client->expects(self::never())->method('manifest');
|
||||||
|
$this->client = $client;
|
||||||
|
|
||||||
|
$report = $this->syncer()->run();
|
||||||
|
|
||||||
|
self::assertNotNull($report->stoppedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncer(string $now = '2026-10-08 12:00:00'): ContentSyncer
|
||||||
|
{
|
||||||
|
$repository = $this->createStub(ContentDocumentRepository::class);
|
||||||
|
$repository->method('findAllByKey')->willReturnCallback(fn (): array => $this->stored);
|
||||||
|
|
||||||
|
$stateRepository = $this->createStub(ContentSyncStateRepository::class);
|
||||||
|
$stateRepository->method('get')->willReturn($this->state);
|
||||||
|
|
||||||
|
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||||
|
$entityManager->method('persist')->willReturnCallback(function (object $entity): void {
|
||||||
|
if ($entity instanceof ContentDocument) {
|
||||||
|
$this->stored[$entity->getKey()] = $entity;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$entityManager->method('remove')->willReturnCallback(function (object $entity): void {
|
||||||
|
if ($entity instanceof ContentDocument) {
|
||||||
|
unset($this->stored[$entity->getKey()]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$entityManager->method('contains')->willReturnCallback(fn (object $entity): bool => $entity instanceof ContentDocument && ($this->stored[$entity->getKey()] ?? null) === $entity);
|
||||||
|
|
||||||
|
$converter = new HtmlToMarkdownConverter();
|
||||||
|
|
||||||
|
return new ContentSyncer(
|
||||||
|
$this->client,
|
||||||
|
$repository,
|
||||||
|
$stateRepository,
|
||||||
|
new DocumentBodyBuilder($converter),
|
||||||
|
$converter,
|
||||||
|
$this->createStub(TripCatalog::class),
|
||||||
|
$entityManager,
|
||||||
|
new MockClock(new \DateTimeImmutable($now, new \DateTimeZone('UTC'))),
|
||||||
|
new NullLogger(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<ManifestItem> $items
|
||||||
|
*/
|
||||||
|
private function manifest(array $items): ContentManifest
|
||||||
|
{
|
||||||
|
$keyed = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$keyed[$item->key()] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ContentManifest(new \DateTimeImmutable('2026-10-08 08:15:00'), $keyed, ['www.ep-reisen.de']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{type: string, uid: int}> $detailOf
|
||||||
|
*/
|
||||||
|
private function pageItem(int $uid, array $detailOf = []): ManifestItem
|
||||||
|
{
|
||||||
|
return new ManifestItem('page', $uid, 'de', 'ep-reisen', 'https://www.ep-reisen.de/page-'.$uid, new \DateTimeImmutable('2026-09-30 12:00:00'), 'https://www.ep-reisen.de/page-'.$uid.'?type=1730', 'Page '.$uid, null, ['Skireisen'], $detailOf);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordItem(string $type, int $uid, ?string $url = 'https://www.ep-reisen.de/record'): ManifestItem
|
||||||
|
{
|
||||||
|
return new ManifestItem($type, $uid, 'de', null === $url ? null : 'ep-reisen', $url, new \DateTimeImmutable('2026-09-30 12:00:00'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function document(string $type, int $uid): ContentDocument
|
||||||
|
{
|
||||||
|
return new ContentDocument($type, $uid, 'de', new \DateTimeImmutable('2026-09-01 12:00:00'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\ContentFeed;
|
||||||
|
|
||||||
|
use App\ContentFeed\DocumentBodyBuilder;
|
||||||
|
use App\ContentFeed\HtmlToMarkdownConverter;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class DocumentBodyBuilderTest extends TestCase
|
||||||
|
{
|
||||||
|
private DocumentBodyBuilder $builder;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->builder = new DocumentBodyBuilder(new HtmlToMarkdownConverter());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testProductSectionsInFixedOrderWithFaqs(): void
|
||||||
|
{
|
||||||
|
$body = $this->builder->build('product', [
|
||||||
|
'url' => 'https://www.ep-reisen.de/val-thorens/',
|
||||||
|
'name' => 'Val Thorens Apartments',
|
||||||
|
'subline' => 'Apartments',
|
||||||
|
'headline' => 'Skiurlaub in Val Thorens',
|
||||||
|
'text' => [
|
||||||
|
'skiPassDescription' => '<p>6 Tage 3 Vallées</p>',
|
||||||
|
'teaser' => '<p>Ski in, ski out</p>',
|
||||||
|
'includedServices' => '<ul><li>Unterkunft</li></ul>',
|
||||||
|
'feature' => null,
|
||||||
|
],
|
||||||
|
'faqs' => [
|
||||||
|
['question' => 'Ist der Skipass inklusive?', 'answer' => '<p>Ja.</p>', 'category' => null],
|
||||||
|
['question' => 'Ohne Antwort', 'answer' => null],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
"# Val Thorens Apartments\n\nApartments\n\nSkiurlaub in Val Thorens\n\nSki in, ski out\n\n## Leistungen\n\n- Unterkunft\n\n## Skipass\n\n6 Tage 3 Vallées\n\n## Häufige Fragen\n\n### Ist der Skipass inklusive?\n\nJa.",
|
||||||
|
$body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHotelFactsAreSearchableText(): void
|
||||||
|
{
|
||||||
|
$body = $this->builder->build('hotel', [
|
||||||
|
'name' => 'Chalets de Rosael',
|
||||||
|
'facts' => ['bedsCount' => 120, 'altitude' => null, 'sauna' => true, 'wifi' => false],
|
||||||
|
'text' => ['description' => '<p>Ruhige Lage</p>'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame("# Chalets de Rosael\n\n## Beschreibung\n\nRuhige Lage\n\n## Fakten\n\n- Betten: 120\n- Sauna: ja\n- WLAN: nein", $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNewsUsesTitleDateBodyAndTags(): void
|
||||||
|
{
|
||||||
|
$record = [
|
||||||
|
'title' => 'Packlisten',
|
||||||
|
'teaser' => 'Was muss mit?',
|
||||||
|
'bodytext' => '<p>Helm & Brille</p>',
|
||||||
|
'datetime' => '2026-07-23T14:00:00+02:00',
|
||||||
|
'tags' => ['Urlaubsvorbereitung', ['title' => 'Anfänger:in']],
|
||||||
|
];
|
||||||
|
|
||||||
|
self::assertSame('Packlisten', $this->builder->title('news', $record));
|
||||||
|
self::assertSame("# Packlisten\n\nVeröffentlicht am 23.07.2026\n\nWas muss mit?\n\nHelm & Brille\n\nThemen: Urlaubsvorbereitung, Anfänger:in", $this->builder->build('news', $record));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRecordWithoutTextIsJustItsTitle(): void
|
||||||
|
{
|
||||||
|
self::assertSame('# Frankreich', $this->builder->build('country', ['name' => 'Frankreich', 'text' => []]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\ContentFeed;
|
||||||
|
|
||||||
|
use App\ContentFeed\HtmlToMarkdownConverter;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class HtmlToMarkdownConverterTest extends TestCase
|
||||||
|
{
|
||||||
|
private HtmlToMarkdownConverter $converter;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->converter = new HtmlToMarkdownConverter();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDropsEmbedsMediaAndFormsWithTheirContent(): void
|
||||||
|
{
|
||||||
|
$markdown = $this->converter->convert('<p>Text</p><script>alert(1)</script><style>p{}</style><noscript>No JS</noscript><iframe src="https://youtube.com"></iframe><img src="a.jpg" alt="Bild"><picture><source></picture><svg><text>Icon</text></svg><form><label>Name</label></form>');
|
||||||
|
|
||||||
|
self::assertSame('Text', $markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeepsHeadingsListsTablesAndEmphasis(): void
|
||||||
|
{
|
||||||
|
$markdown = $this->converter->convert('<h2>Leistungen</h2><ul><li><strong>Skipass</strong></li><li><em>Bus</em></li></ul><table><tr><th>A</th><th>B</th></tr><tr><td>1</td><td>2</td></tr></table>');
|
||||||
|
|
||||||
|
self::assertSame("## Leistungen\n\n- **Skipass**\n- *Bus*\n\n| A | B |\n|---|---|\n| 1 | 2 |", $markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResolvesRelativeLinksAgainstTheDocumentUrl(): void
|
||||||
|
{
|
||||||
|
$markdown = $this->converter->convert('<a href="/agb">AGB</a> <a href="kontakt">Kontakt</a> <a href="mailto:[email protected]">Mail</a> <a href="https://snowzone.net/">SZ</a>', 'https://www.ep-reisen.de/info/reise');
|
||||||
|
|
||||||
|
self::assertSame('[AGB](https://www.ep-reisen.de/agb) [Kontakt](https://www.ep-reisen.de/info/kontakt) [Mail](mailto:[email protected]) [SZ](https://snowzone.net/)', $markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCollapsesTemplateWhitespaceAndDecodesEntities(): void
|
||||||
|
{
|
||||||
|
$markdown = $this->converter->convert("<div>\n <a href=\"/x\">\n Ski-\n Gruppenhäuser\n </a></div>\n\n\n\n<p>Essen & Trinken</p>", 'https://www.ep-reisen.de/');
|
||||||
|
|
||||||
|
self::assertSame("[Ski- Gruppenhäuser](https://www.ep-reisen.de/x)\n\nEssen & Trinken", $markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptyInputIsEmpty(): void
|
||||||
|
{
|
||||||
|
self::assertSame('', $this->converter->convert(null));
|
||||||
|
self::assertSame('', $this->converter->convert(' '));
|
||||||
|
self::assertSame('', $this->converter->convert('<article><img src="a.jpg"></article>'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Security;
|
||||||
|
|
||||||
|
use App\Security\McpAccessTokenHandler;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
|
||||||
|
|
||||||
|
class McpAccessTokenHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testTheConfiguredTokenAuthenticatesTheN8nUser(): void
|
||||||
|
{
|
||||||
|
self::assertSame(McpAccessTokenHandler::USER, (new McpAccessTokenHandler('s3cret'))->getUserBadgeFrom('s3cret')->getUserIdentifier());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAWrongTokenIsRejected(): void
|
||||||
|
{
|
||||||
|
$this->expectException(BadCredentialsException::class);
|
||||||
|
|
||||||
|
(new McpAccessTokenHandler('s3cret'))->getUserBadgeFrom('guess');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnUnconfiguredTokenRejectsEverything(): void
|
||||||
|
{
|
||||||
|
$this->expectException(BadCredentialsException::class);
|
||||||
|
|
||||||
|
(new McpAccessTokenHandler(''))->getUserBadgeFrom('');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user