Перейти к содержанию

Canonical, robots.txt и карта сайта

Наш учебный сайт уже содержит статьи, каталог, последовательность уроков и поиск. У каждой страницы есть постоянный путь: например, /my/legacy/first.php. Однако для поисковой системы путь без домена ещё не является полным адресом. Нужно определить, на каком домене находится основная версия сайта и какие страницы предназначены для внешнего поиска.

Добавим в markdown-site один источник настройки домена, метатеги общего шаблона и два служебных файла. Все адреса будут получаться из уже существующего реестра pages. Это продолжает подход переноса ProfessorWeb: сохранённый адрес статьи, ссылка каталога и запись карты сайта должны описывать одну страницу.

Основной адрес и каноническая ссылка

Рассмотрим вторую статью. При выбранном домене https://example.com её полным адресом будет https://example.com/articles/second.html. Если тот же материал случайно доступен через другой домен, через HTTP или с параметром рекламной кампании, поисковому роботу потребуется определить основную версию. Элемент link с rel="canonical" передаёт предпочтительный адрес. Это сигнал для канонизации; окончательное решение поисковая система принимает по совокупности данных. Такое назначение описано в руководстве Google по каноническим адресам.

Создайте в корне учебного проекта файл site.yaml:

base_url: https://example.com

example.com используется только как обозначение учебного домена. Перед настоящей публикацией его заменяют собственным основным адресом. Домены ProfessorWeb в этот файл не подставляем: наш небольшой генератор остаётся самостоятельным примером.

В build.py добавьте импорт:

import xml.etree.ElementTree as ET

После определения ROOT и DIST добавьте переменную BASE_URL = "". Она получит проверенное значение в начале main(). Затем перед main() разместите полные определения:

def load_site_config():
    config = yaml.safe_load((ROOT / "site.yaml").read_text(encoding="utf-8"))
    if not isinstance(config, dict):
        raise ValueError("site.yaml должен содержать словарь")
    value = config.get("base_url")
    if not isinstance(value, str) or value != value.strip():
        raise ValueError("base_url должен быть строкой без крайних пробелов")
    if any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in value):
        raise ValueError("Пробелы и управляющие символы в base_url запрещены")
    if any(char in value for char in ("?", "#", "\\")):
        raise ValueError("Параметры, фрагменты и обратные слеши в base_url запрещены")
    parsed = urlsplit(value)
    try:
        parsed.port
    except ValueError as error:
        raise ValueError("Некорректный порт в base_url") from error
    if (parsed.scheme not in {"http", "https"} or not parsed.hostname
            or parsed.username is not None or parsed.password is not None
            or parsed.path not in {"", "/"} or parsed.query or parsed.fragment):
        raise ValueError("base_url: нужен http(s)-домен без пути и параметров")
    return value.rstrip("/")


def validate_discovery(pages):
    reserved = {"sitemap.xml", "robots.txt"}
    for name in reserved:
        path = ROOT / "public" / name
        if path.exists() or path.is_symlink():
            raise ValueError(f"{name} создаёт сборщик; уберите его из public")
    for page in pages.values():
        value = page.get("noindex", False)
        if type(value) is not bool:
            raise ValueError(f'{page["permalink"]}: noindex должен быть boolean')
        page["noindex"] = value


def seo_tags(page):
    canonical = escape(BASE_URL + page["permalink"], quote=True)
    tags = [f'<link rel="canonical" href="{canonical}">']
    if page.get("noindex", False):
        tags.append('<meta name="robots" content="noindex, follow">')
    return "\n".join(tags)

Настройку домена читаем один раз для всей сборки. Отсутствующий файл или адрес вида https://example.com/some-folder вызывают ошибку: все наши permalink уже начинаются от корня, и дополнительная папка изменила бы их смысл. Проверка noindex тоже выполняется заранее, пока прежняя папка dist ещё не очищена.

В HTML атрибут должен содержать экранированную строку. Сначала соединяем проверенный домен с постоянным путём, затем вызываем escape. Мы не добавляем адрес исходного Markdown-файла и не заменяем .php на .html: канонический адрес сохранённого материала остаётся тем, который читатель уже использует.

В layouts/page.html поместите строку {{ seo }} внутри head, например после метатега описания. Затем целиком замените существующую функцию write_page():

def write_page(page, navigation=""):
    context = {
        "title": escape(page["title"], quote=True),
        "description": escape(page["description"], quote=True),
        "body": page["body"],
        "page_navigation": navigation,
        "seo": seo_tags(page),
    }
    output = DIST / permalink_to_path(page["permalink"])
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(render_layout(context), encoding="utf-8")

Для второй статьи ожидается следующий фрагмент head:

<link rel="canonical" href="https://example.com/articles/second.html">

Сам по себе этот элемент не перенаправляет браузер и не исправляет серверный ответ. Если HTTP и HTTPS доступны одновременно, направление перенаправления настраивается на хостинге. Наш генератор пока отвечает только за содержание документа.

Служебная страница и доступ робота

В прошлой главе страница поиска получила noindex: True в словаре Python. Теперь общий шаблон преобразует его в метатег. Статья может получить такое же указание из YAML:

noindex: true

Значение записывается без кавычек. Строка "true" не принимается как логическое значение, потому что она может скрывать опечатку в настройке. По умолчанию noindex равен false; обычные статьи и каталожные страницы открыты для индексации.

Не следует одновременно закрывать страницу поиска правилом Disallow: /search/ и рассчитывать, что робот прочитает её метатег. robots.txt управляет обходом, а noindex должен быть получен вместе со страницей. Если обход запрещён, робот может не увидеть указание об исключении. Это условие прямо объясняет документация Google по noindex. Для нашего открытого служебного поиска оставляем обход разрешённым.

Обратите внимание: noindex не является защитой конфиденциальных данных. Файл существует на сервере и доступен посетителю по прямой ссылке. Закрытый черновик мы по-прежнему исключаем из documents и вообще не записываем в dist. Для приватного тестового сайта понадобится ограничение доступа на сервере, которое рассматривается в уроке о публикации.

Карта из реестра страниц

Карта сайта сообщает поисковой системе список выбранных полных адресов. В неё должны попасть не только статьи, но и каталоги, по которым робот найдёт дальнейшие материалы. Вместо отдельного вручную поддерживаемого списка используем pages, уже содержащий все эти документы.

Добавьте в build.py следующую функцию целиком:

def write_discovery(pages):
    namespace = "http://www.sitemaps.org/schemas/sitemap/0.9"
    ET.register_namespace("", namespace)
    root = ET.Element(f"{{{namespace}}}urlset")
    urls = [page for page in pages.values() if not page["noindex"]]
    if len(urls) > 50000:
        raise ValueError("Для более 50000 адресов разделите sitemap")

    for page in sorted(urls, key=lambda item: item["permalink"]):
        node = ET.SubElement(root, f"{{{namespace}}}url")
        ET.SubElement(node, f"{{{namespace}}}loc").text = (
            BASE_URL + page["permalink"]
        )

    xml = ET.tostring(root, encoding="utf-8", xml_declaration=True)
    if len(xml) > 50 * 1024 * 1024:
        raise ValueError("Для sitemap больше 50 МБ нужны отдельные файлы")
    (DIST / "sitemap.xml").write_bytes(xml)
    (DIST / "robots.txt").write_text(
        "User-agent: *\nAllow: /\n"
        f"Sitemap: {BASE_URL}/sitemap.xml\n",
        encoding="utf-8",
    )

XML формируется специальной библиотекой, а не склейкой произвольных строк. Она записывает пространство имён Sitemap и экранирует значения XML. Стабильная сортировка адресов помогает сравнивать результаты двух сборок: изменение порядка исходных файлов не создаст лишних различий в карте.

Мы намеренно не добавляем lastmod с текущим временем каждой сборки. Пересборка шаблона ещё не означает содержательного изменения статьи. Если позднее появится достоверная дата редактирования материала, её можно передавать отдельным полем. В нынешнем проекте полезнее карта без даты, чем дата, которая регулярно вводит робота в заблуждение.

Одна карта допускает до 50 000 адресов и до 50 МБ несжатого содержимого; при превышении её разделяют и при необходимости создают индекс карт. Требования приведены в руководстве Google по созданию Sitemap. Библиотека из двадцати тысяч страниц может поместиться в один файл, но решение принимается также по фактическому размеру XML.

Обновите main() целиком:

def main():
    global BASE_URL
    BASE_URL = load_site_config()
    documents = load_documents()
    pages = {document["permalink"]: document for document in documents}
    build_catalog(documents, pages)
    build_search(documents, pages)
    navigation = build_navigation(documents)
    validate_public(pages)
    validate_discovery(pages)

    if DIST.exists():
        shutil.rmtree(DIST)
    DIST.mkdir()
    copy_public()
    for page in pages.values():
        write_page(page, navigation.get(page["permalink"], ""))
    write_search_index(documents)
    write_discovery(pages)
    print(f"Создано страниц: {len(pages)}")

Согласованность адресов

После сборки ожидаются восемь HTML-страниц и семь записей loc: три статьи и четыре каталожные страницы. Поиск существует и работает, но имеет noindex и отсутствует в карте. Служебные файлы и ресурсы не превращаются в страницы Sitemap.

Рассмотрим изменение: во вторую статью добавлено noindex: true. Она останется в каталоге и во внутреннем поиске, потому что материал опубликован для посетителей. Однако её канонический адрес исчезнет из Sitemap, а в head появится запрет индексации. Если вместо этого поставить draft: true, статья вообще перестанет выпускаться. Когда она входит в first-course, сборщик навигации также сообщит об отсутствующем участнике: сначала потребуется осмысленно обновить учебную последовательность.

После рассмотренного изменения верните вторую статью в обычное состояние: удалите поле noindex либо установите noindex: false. Если пробовали вариант с черновиком, также верните draft: false и исходную последовательность first-course. В дальнейших уроках все три статьи опубликованы и предназначены для индексации, а служебный поиск — нет.

При проверке результата сравнивайте три представления одного пути: href каталога, canonical статьи и loc карты. Для сохранённой HTML-страницы все они должны вести на /my/legacy/first.php. Наличие XML не означает, что поисковая система уже прочитала страницы или включила их в выдачу. После публикации карту можно передать через инструменты вебмастера; требования и обработку описывает также справка Яндекс Вебмастера о Sitemap.

Настройка base_url влияет сразу на весь выпуск. Поэтому перед сменой домена полезно обнаруживать несогласованные ссылки автоматически. В следующем уроке добавим отдельную проверку готовой папки: она найдёт потерянные старые пути, отсутствующие ресурсы и повреждённые переходы между уроками.