Карта Sitemap.XML помогает поисковой системе обнаружить канонические URL. Разделим задачу на три части: получить опубликованные записи, сформировать XML и текстовую версию, затем безопасно заменить файлы. Источником служат данные приложения, а не адреса из случайного входящего запроса.
Канонический origin и список записей
Основной адрес задаём в конфигурации, например https://example.com. Не собираем его из HTTP_HOST: этот заголовок задаёт клиент. Адаптер каталога или базы возвращает относительные канонические пути и реальные даты изменения содержимого. Не включаем приватные, исключённые, ошибочные и перенаправляющие страницы. Параметры запроса допустимы только если действительно относятся к выбранному каноническому URL.
DOMDocument: значение как текстовый узел
XML имеет собственное экранирование. URL с параметрами ?category=books&page=2 должен сохраняться в loc как текстовый узел через createTextNode; не склеивайте XML строками и не передавайте необработанный амперсанд вторым аргументом createElement. Пространство имён sitemaps.org остаётся HTTP-идентификатором — это не команда скачивать что-либо по сети.
Пример генератора
Код рассчитан на PHP8 с расширением DOM. Он возвращает два готовых документа, отсеивает дубли и проверяет базовую форму путей и дат. Пример использует короткий список вместо зависимого от старого проекта класса базы; для настоящего сайта замените только адаптер записей.
<?php
declare(strict_types=1);
function sitemapFiles(array $records, string $base): array {
// $base comes from trusted application configuration, never HTTP_HOST.
$origin = parse_url($base);
if (!filter_var($base, FILTER_VALIDATE_URL) || ($origin['scheme'] ?? '') !== 'https' || empty($origin['host']) ||
isset($origin['user']) || isset($origin['pass']) || isset($origin['query']) ||
isset($origin['fragment']) || !in_array($origin['path'] ?? '', ['', '/'], true)) {
throw new InvalidArgumentException('Expected configured HTTPS origin');
}
$base = rtrim($base, '/');
if (count($records) > 50000) throw new LengthException('Split into multiple sitemaps');
$ns = 'http://www.sitemaps.org/schemas/sitemap/0.9';
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->formatOutput = true;
$root = $dom->appendChild($dom->createElementNS($ns, 'urlset'));
$seen = [];
foreach ($records as $record) {
$path = $record['path'] ?? '';
if (!is_string($path) || !str_starts_with($path, '/') ||
str_starts_with($path, '//') || str_contains($path, '#') ||
preg_match('/[\x00-\x20]/', $path)) {
throw new InvalidArgumentException('Expected canonical encoded path');
}
$loc = $base . $path;
if (strlen($loc) >= 2048 || !filter_var($loc, FILTER_VALIDATE_URL)) {
throw new InvalidArgumentException('Invalid URL');
}
if (isset($record['lastmod'])) {
$candidate = $record['lastmod'];
if (!is_string($candidate)) throw new InvalidArgumentException('Invalid lastmod');
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $candidate);
if (!$parsed || $parsed->format('Y-m-d') !== $candidate) throw new InvalidArgumentException('Expected real lastmod YYYY-MM-DD');
}
if (isset($seen[$loc])) continue;
$seen[$loc] = true;
$url = $root->appendChild($dom->createElementNS($ns, 'url'));
$node = $url->appendChild($dom->createElementNS($ns, 'loc'));
$node->appendChild($dom->createTextNode($loc));
if (isset($record['lastmod'])) {
$lastmod = $record['lastmod'];
if (!is_string($lastmod)) throw new InvalidArgumentException('Invalid lastmod');
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $lastmod);
if (!$date || $date->format('Y-m-d') !== $lastmod) {
throw new InvalidArgumentException('Expected real lastmod YYYY-MM-DD');
}
$node = $url->appendChild($dom->createElementNS($ns, 'lastmod'));
$node->appendChild($dom->createTextNode($lastmod));
}
}
$xml = $dom->saveXML();
if ($xml === false || strlen($xml) > 52428800) {
throw new LengthException('Sitemap serialization or size limit');
}
return ['xml' => $xml, 'txt' => implode("\n", array_keys($seen)) . (count($seen) ? "\n" : '')];
}
function writeAtomic(string $destination, string $contents): void {
$directory = dirname($destination);
$temporary = tempnam($directory, '.sitemap-');
if ($temporary === false || dirname($temporary) !== realpath($directory)) {
if ($temporary !== false) unlink($temporary);
throw new RuntimeException('Cannot create temporary file in output directory');
}
try {
if (file_put_contents($temporary, $contents, LOCK_EX) !== strlen($contents)) {
throw new RuntimeException('Incomplete write');
}
if (!chmod($temporary, 0644) || !rename($temporary, $destination)) {
throw new RuntimeException('Cannot replace sitemap');
}
} finally {
if (is_file($temporary)) unlink($temporary);
}
}
// Example data adapter. In an application, replace this list with canonical
// published records from your repository; do not read URLs from user input.
$base = 'https://example.com';
$records = [
['path' => '/', 'lastmod' => '2024-01-15'],
['path' => '/catalog/?category=books&page=2', 'lastmod' => '2024-02-29'],
];
$files = sitemapFiles($records, $base);
// Configure an existing, trusted local output directory before invoking:
// writeAtomic($outputDirectory . '/sitemap.xml', $files['xml']);
// writeAtomic($outputDirectory . '/sitemap.txt', $files['txt']);
Сохранение без окна отсутствующего файла
Удалять рабочий sitemap.xml до записи нового не нужно. writeAtomic создаёт временный файл в том же каталоге, проверяет полную запись и заменяет старый через rename. Такая замена отдельного файла атомарна на обычной POSIX-файловой системе; XML и TXT не становятся одной общей транзакцией. Каталог назначения должен существовать и задаваться серверной конфигурацией. Проверяйте права, ошибки диска и отсутствие зависимостей от каталога запуска.
Что делать после генерации
Укажите Sitemap: https://example.com/sitemap.xml в robots.txt либо отправьте карту через Search Console. Старый Google sitemap-ping отключён; Yahoo-пинг из первоначального примера не нужен. Наличие URL в карте не гарантирует обход или включение в выдачу. Google не использует changefreq и priority; lastmod должен отражать существенное изменение страницы, а не каждый запуск сборки.
Размер и проверки
В одной XML-карте не более 50 000 URL и 50 МБ без сжатия. Большой корпус разбивается на карты и sitemap index. Пример проверяет лимиты одного документа, но не строит индекс автоматически. До публикации разберите готовый XML другим парсером и проверьте, что URL с амперсандом восстановился без потерь, даты существуют, пути принадлежат выбранному origin, а файл остаётся прежним при неудачной записи.
Источники: протокол Sitemap, DOMDocument и текстовые значения, Google: lastmod и прекращение ping.