codeisart.

PHP

Генерация Sitemap.XML с помощью DOMDocument

Карта Sitemap.XML помогает поисковой системе обнаружить канонические URL. Разделим задачу на три части: получить опубликованные записи, сформировать XML и текстовую…

В этой статье

Карта 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.