Sitemap lastmod and handling of past events
- SitemapListener: <lastmod> for event detail pages (event tstamp) and regular pages; events that ended more than 12 months ago are removed from the sitemap - PastEventPolicy: events stay indexable for 12 months after they end (recurring events are searched before the next date), afterwards the detail page gets noindex,follow but stays reachable as archive - detail template data: is_past and next_occurrence (next published event with the same title) for a notice with a link to the next date Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5bf31e5043
commit
7df08b9017
3 files changed
+207
No files matched your search
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Mummert\EventManagerBundle\EventListener;
|
namespace Mummert\EventManagerBundle\EventListener;
|
||||||
|
|
||||||
|
use Contao\CalendarEventsModel;
|
||||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
|
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
|
||||||
|
use Contao\CoreBundle\Routing\ContentUrlGenerator;
|
||||||
use Contao\CoreBundle\Routing\ResponseContext\HtmlHeadBag\HtmlHeadBag;
|
use Contao\CoreBundle\Routing\ResponseContext\HtmlHeadBag\HtmlHeadBag;
|
||||||
use Contao\CoreBundle\Routing\ResponseContext\ResponseContextAccessor;
|
use Contao\CoreBundle\Routing\ResponseContext\ResponseContextAccessor;
|
||||||
use Contao\StringUtil;
|
use Contao\StringUtil;
|
||||||
@@ -12,6 +14,7 @@ use Contao\Template;
|
|||||||
use Doctrine\DBAL\Connection;
|
use Doctrine\DBAL\Connection;
|
||||||
use Doctrine\DBAL\ParameterType;
|
use Doctrine\DBAL\ParameterType;
|
||||||
use Mummert\EventManagerBundle\Seo\EventMetaTitle;
|
use Mummert\EventManagerBundle\Seo\EventMetaTitle;
|
||||||
|
use Mummert\EventManagerBundle\Seo\PastEventPolicy;
|
||||||
|
|
||||||
#[AsHook('parseTemplate', method: 'onParseTemplate')]
|
#[AsHook('parseTemplate', method: 'onParseTemplate')]
|
||||||
class EventFullTemplateDataListener
|
class EventFullTemplateDataListener
|
||||||
@@ -21,6 +24,7 @@ class EventFullTemplateDataListener
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Connection $connection,
|
private readonly Connection $connection,
|
||||||
private readonly ResponseContextAccessor $responseContextAccessor,
|
private readonly ResponseContextAccessor $responseContextAccessor,
|
||||||
|
private readonly ContentUrlGenerator $contentUrlGenerator,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +51,7 @@ class EventFullTemplateDataListener
|
|||||||
|
|
||||||
$this->setMetaTitle($data);
|
$this->setMetaTitle($data);
|
||||||
$this->setMetaDescriptionFallback($data, $locationRow);
|
$this->setMetaDescriptionFallback($data, $locationRow);
|
||||||
|
$this->applyPastEventPolicy($template, $data, $eventId);
|
||||||
|
|
||||||
$template->location_row = $locationRow;
|
$template->location_row = $locationRow;
|
||||||
$template->organizations = $organizations;
|
$template->organizations = $organizations;
|
||||||
@@ -138,6 +143,56 @@ class EventFullTemplateDataListener
|
|||||||
$headBag->setMetaDescription($description.'. Termin und Infos im Veranstaltungskalender Nossener Land.');
|
$headBag->setMetaDescription($description.'. Termin und Infos im Veranstaltungskalender Nossener Land.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vergangene Events: Hinweis + Link auf den nächsten Termin gleichen Namens;
|
||||||
|
* länger als PastEventPolicy::INDEX_MONTHS vorbei: noindex,follow.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private function applyPastEventPolicy(Template $template, array $data, int $eventId): void
|
||||||
|
{
|
||||||
|
$template->is_past = PastEventPolicy::isPast($data);
|
||||||
|
$template->next_occurrence = null;
|
||||||
|
|
||||||
|
if (!$template->is_past) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PastEventPolicy::isOutsideIndexWindow($data)) {
|
||||||
|
$headBag = $this->responseContextAccessor->getResponseContext()?->get(HtmlHeadBag::class);
|
||||||
|
|
||||||
|
if ($headBag instanceof HtmlHeadBag) {
|
||||||
|
$headBag->setMetaRobots('noindex,follow');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nächster veröffentlichter Termin mit gleichem (gespeicherten) Titel
|
||||||
|
$nextId = $this->connection->fetchOne(
|
||||||
|
"SELECT e.id FROM tl_calendar_events e
|
||||||
|
WHERE e.published = 1 AND e.id != ?
|
||||||
|
AND LOWER(TRIM(e.title)) = (SELECT LOWER(TRIM(o.title)) FROM tl_calendar_events o WHERE o.id = ?)
|
||||||
|
AND COALESCE(NULLIF(e.endDate, 0), e.startDate) >= ?
|
||||||
|
AND (e.start = '' OR e.start <= ?) AND (e.stop = '' OR e.stop > ?)
|
||||||
|
ORDER BY e.startDate ASC LIMIT 1",
|
||||||
|
[$eventId, $eventId, strtotime('today'), time(), time()],
|
||||||
|
);
|
||||||
|
|
||||||
|
$next = $nextId ? CalendarEventsModel::findById((int) $nextId) : null;
|
||||||
|
|
||||||
|
if (null === $next) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$template->next_occurrence = [
|
||||||
|
'url' => $this->contentUrlGenerator->generate($next),
|
||||||
|
'date' => EventMetaTitle::formatDate((int) $next->startDate, (int) $next->endDate),
|
||||||
|
];
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Kein Link möglich (z. B. Archiv ohne Zielseite): nur der Hinweis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string, string> */
|
/** @return array<string, string> */
|
||||||
private function fetchLocationRow(int $locationId): array
|
private function fetchLocationRow(int $locationId): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mummert\EventManagerBundle\EventListener;
|
||||||
|
|
||||||
|
use Contao\CoreBundle\Event\SitemapEvent;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Mummert\EventManagerBundle\Seo\PastEventPolicy;
|
||||||
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergänzt die Contao-Sitemap:
|
||||||
|
*
|
||||||
|
* - <lastmod> für Event-Detailseiten (tstamp des Events) und reguläre Seiten
|
||||||
|
* (tstamp der Seite), damit Suchmaschinen geänderte Seiten gezielt neu abrufen
|
||||||
|
* - entfernt Events, deren Ende länger als PastEventPolicy::INDEX_MONTHS zurückliegt;
|
||||||
|
* diese Seiten stehen auf noindex,follow (EventFullTemplateDataListener)
|
||||||
|
*
|
||||||
|
* Läuft nach dem SitemapListener des Calendar-Bundles (der die Events einfügt).
|
||||||
|
*/
|
||||||
|
#[AsEventListener(priority: -100)]
|
||||||
|
class SitemapListener
|
||||||
|
{
|
||||||
|
private const NS = 'http://www.sitemaps.org/schemas/sitemap/0.9';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Connection $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(SitemapEvent $event): void
|
||||||
|
{
|
||||||
|
$document = $event->getDocument();
|
||||||
|
$xpath = new \DOMXPath($document);
|
||||||
|
$xpath->registerNamespace('s', self::NS);
|
||||||
|
|
||||||
|
$events = $this->loadEvents();
|
||||||
|
$pages = $this->loadPages();
|
||||||
|
|
||||||
|
/** @var \DOMElement $url */
|
||||||
|
foreach (iterator_to_array($xpath->query('//s:url')) as $url) {
|
||||||
|
$loc = $xpath->query('s:loc', $url)->item(0);
|
||||||
|
|
||||||
|
if (!$loc instanceof \DOMElement || $xpath->query('s:lastmod', $url)->length > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = rawurldecode(trim((string) parse_url(trim($loc->textContent), PHP_URL_PATH), '/'));
|
||||||
|
$segments = '' === $path ? [] : explode('/', $path);
|
||||||
|
$lastmod = null;
|
||||||
|
|
||||||
|
if (\count($segments) >= 2 && isset($events[end($segments)])) {
|
||||||
|
$row = $events[end($segments)];
|
||||||
|
|
||||||
|
if (PastEventPolicy::isOutsideIndexWindow($row)) {
|
||||||
|
$url->parentNode?->removeChild($url);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastmod = (int) $row['tstamp'];
|
||||||
|
} elseif (isset($pages[$path])) {
|
||||||
|
$lastmod = $pages[$path];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($lastmod > 0) {
|
||||||
|
$element = $document->createElementNS(self::NS, 'lastmod', date('Y-m-d\TH:i:sP', $lastmod));
|
||||||
|
$loc->parentNode?->insertBefore($element, $loc->nextSibling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<string, mixed>> alias => Event (bei doppelten Aliassen der neueste Stand)
|
||||||
|
*/
|
||||||
|
private function loadEvents(): array
|
||||||
|
{
|
||||||
|
$events = [];
|
||||||
|
|
||||||
|
foreach ($this->connection->fetchAllAssociative('SELECT alias, tstamp, startDate, endDate FROM tl_calendar_events WHERE published = 1 ORDER BY tstamp') as $row) {
|
||||||
|
if ('' !== (string) $row['alias']) {
|
||||||
|
$events[(string) $row['alias']] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int> URL-Pfad => tstamp ("" = Startseite)
|
||||||
|
*/
|
||||||
|
private function loadPages(): array
|
||||||
|
{
|
||||||
|
$pages = [];
|
||||||
|
|
||||||
|
foreach ($this->connection->fetchAllAssociative("SELECT alias, tstamp FROM tl_page WHERE type = 'regular' AND published = 1") as $row) {
|
||||||
|
$alias = (string) $row['alias'];
|
||||||
|
$pages['index' === $alias ? '' : $alias] = (int) $row['tstamp'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pages;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mummert\EventManagerBundle\Seo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Umgang mit vergangenen Veranstaltungen.
|
||||||
|
*
|
||||||
|
* Bis INDEX_MONTHS nach dem Ende bleiben Events indexierbar (wiederkehrende Events
|
||||||
|
* wie Weihnachtsmärkte werden vor dem nächsten Termin gesucht). Danach:
|
||||||
|
* noindex,follow und nicht mehr in der Sitemap. Die Seiten bleiben als Archiv erreichbar.
|
||||||
|
*/
|
||||||
|
final class PastEventPolicy
|
||||||
|
{
|
||||||
|
public const INDEX_MONTHS = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $event (startDate, endDate)
|
||||||
|
*/
|
||||||
|
public static function endTimestamp(array $event): int
|
||||||
|
{
|
||||||
|
$end = (int) ($event['endDate'] ?? 0) ?: (int) ($event['startDate'] ?? 0);
|
||||||
|
|
||||||
|
// Ende des letzten Veranstaltungstags
|
||||||
|
return $end > 0 ? (int) strtotime('tomorrow', $end) - 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $event
|
||||||
|
*/
|
||||||
|
public static function isPast(array $event, ?int $now = null): bool
|
||||||
|
{
|
||||||
|
$end = self::endTimestamp($event);
|
||||||
|
|
||||||
|
return $end > 0 && $end < ($now ?? time());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $event
|
||||||
|
*/
|
||||||
|
public static function isOutsideIndexWindow(array $event, ?int $now = null): bool
|
||||||
|
{
|
||||||
|
$end = self::endTimestamp($event);
|
||||||
|
|
||||||
|
return $end > 0 && $end < (int) strtotime('-'.self::INDEX_MONTHS.' months', $now ?? time());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user