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;
|
||||
|
||||
use Contao\CalendarEventsModel;
|
||||
use Contao\CoreBundle\DependencyInjection\Attribute\AsHook;
|
||||
use Contao\CoreBundle\Routing\ContentUrlGenerator;
|
||||
use Contao\CoreBundle\Routing\ResponseContext\HtmlHeadBag\HtmlHeadBag;
|
||||
use Contao\CoreBundle\Routing\ResponseContext\ResponseContextAccessor;
|
||||
use Contao\StringUtil;
|
||||
@@ -12,6 +14,7 @@ use Contao\Template;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use Mummert\EventManagerBundle\Seo\EventMetaTitle;
|
||||
use Mummert\EventManagerBundle\Seo\PastEventPolicy;
|
||||
|
||||
#[AsHook('parseTemplate', method: 'onParseTemplate')]
|
||||
class EventFullTemplateDataListener
|
||||
@@ -21,6 +24,7 @@ class EventFullTemplateDataListener
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly ResponseContextAccessor $responseContextAccessor,
|
||||
private readonly ContentUrlGenerator $contentUrlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -47,6 +51,7 @@ class EventFullTemplateDataListener
|
||||
|
||||
$this->setMetaTitle($data);
|
||||
$this->setMetaDescriptionFallback($data, $locationRow);
|
||||
$this->applyPastEventPolicy($template, $data, $eventId);
|
||||
|
||||
$template->location_row = $locationRow;
|
||||
$template->organizations = $organizations;
|
||||
@@ -138,6 +143,56 @@ class EventFullTemplateDataListener
|
||||
$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> */
|
||||
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