Проверки сборки и защита старых адресов
Сборщик нашего проекта проверяет метаданные и не разрешает двум исходникам занять один адрес. Но эти проверки ещё не отвечают на вопрос, пригодна ли папка dist для публикации. В статье можно написать ссылку на отсутствующий рисунок, а при изменении старого материала случайно заменить его постоянный путь. Программа выполнится, хотя посетитель получит повреждённую страницу.
Добавим отдельный check.py, который прочитает готовые файлы и сравнит их с ожидаемой структурой. Особое внимание уделим сохранённым адресам. В опыте ProfessorWeb именно они связывали обновлённые материалы со старыми ссылками, поэтому сохранение URL нужно описывать проверяемым условием, а не только договорённостью редакторов.
Список адресов, которые нельзя потерять
В проекте есть три статьи. Создайте data/protected-routes.yaml с полным содержимым:
routes:
- /index.html
- /articles/second.html
- /my/legacy/first.php
Это небольшой список обязательств для исходно индексируемых статей; служебный поиск и другие страницы noindex в него не включаем. Он не генерирует страницы и не меняет их адреса. При каждой проверке мы требуем, чтобы эти пути всё ещё присутствовали в реестре, соответствовали существующим файлам и оставались открытыми для индексации. Если автор переименует content/legacy.md, но оставит прежний permalink, условие продолжит выполняться. Если он изменит permalink, проверка укажет потерянный старый путь.
Для большого переноса такой список получают до переработки сайта: из исходных файлов, старой карты, журналов запросов и доступных отчётов вебмастера. Одного внешнего источника может быть недостаточно. Затем список сохраняют под контролем версий отдельно от новой структуры контента. Он представляет старые адреса, которые новая сборка обязана обслуживать.
Если страницу действительно требуется удалить или заменить, изменение этого файла должно быть отдельным осмысленным решением. Удалить строку только ради зелёной проверки — значит отказаться от самого условия сохранения. В нашем учебном примере редиректы не вводятся, поэтому защищённый адрес должен продолжать вести к собственной HTML-странице.
Общий реестр для сборки и проверки
Проверка не должна иметь собственный список адресов каталога. Иначе при добавлении нового раздела придётся изменять две независимые реализации. Выделим часть существующего main() в функцию collect_pages(). Добавьте её перед main():
def collect_pages(documents):
pages = {document["permalink"]: document for document in documents}
build_catalog(documents, pages)
build_search(documents, pages)
validate_discovery(pages)
return pages
Теперь целиком замените main():
def main():
global BASE_URL
BASE_URL = load_site_config()
documents = load_documents()
pages = collect_pages(documents)
navigation = build_navigation(documents)
validate_public(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)}")
Функция возвращает ожидаемые страницы, не записывая их на диск. check.py сможет импортировать build и вызвать её. Условие if __name__ == "__main__": main() в конце сборщика по-прежнему необходимо: оно позволяет использовать определения без автоматического запуска сборки при импорте.
Подчеркнём границу процессов. build.py создаёт результат. check.py рассматривает уже созданный результат и ничего в нём не исправляет. Если отсутствует рисунок, проверка должна завершиться ошибкой, а автор — исправить исходную ссылку или вернуть ресурс. Скрытое исправление файла внутри проверки затруднило бы воспроизводимость сборки.
Проверка страниц, ссылок и соседних уроков
Создайте в корне учебного проекта файл check.py. Ниже приведено его полное содержимое. Используются стандартные библиотеки Python и PyYAML, который уже входит в зависимости серии.
from html.parser import HTMLParser
from urllib.parse import unquote, urljoin, urlsplit
import json
import xml.etree.ElementTree as ET
import yaml
import build
class PageHTML(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.references = []
self.ids = set()
self.canonicals = []
self.robots = []
self.neighbours = {"prev": [], "next": []}
def handle_starttag(self, tag, attrs):
values = dict(attrs)
if values.get("id"):
self.ids.add(values["id"])
rel = values.get("rel", "").split()
if tag == "link" and "canonical" in rel:
self.canonicals.append(values.get("href", ""))
if tag == "meta" and values.get("name", "").lower() == "robots":
self.robots.extend(values.get("content", "").lower().replace(
",", " "
).split())
if tag == "a":
for direction in self.neighbours:
if direction in rel:
self.neighbours[direction].append(values.get("href", ""))
attributes = []
if tag in {"a", "link"}:
attributes.append("href")
if tag in {"img", "script", "source", "video", "audio", "iframe"}:
attributes.append("src")
if tag == "video":
attributes.append("poster")
for name in attributes:
value = values.get(name)
if value is not None:
self.references.append((tag, value))
def parse_html(text):
page = PageHTML()
page.feed(text)
page.close()
return page
def local_reference(current, reference, base_url):
parsed = urlsplit(urljoin(base_url + current, reference))
origin = urlsplit(base_url)
if (parsed.scheme, parsed.netloc) != (origin.scheme, origin.netloc):
return None
path = unquote(parsed.path)
if path.endswith("/"):
path += "index.html"
candidate = (build.DIST / path.lstrip("/")).resolve()
if not candidate.is_relative_to(build.DIST.resolve()):
raise ValueError(f"Ссылка выходит за dist: {reference}")
return path, candidate, unquote(parsed.fragment)
def main():
errors = []
base_url = build.load_site_config()
documents = build.load_documents()
expected = build.collect_pages(documents)
navigation = build.build_navigation(documents)
parsed_pages = {}
graph = {url: set() for url in expected}
config = yaml.safe_load((build.ROOT / "data" / "protected-routes.yaml"
).read_text(encoding="utf-8"))
routes = config.get("routes") if isinstance(config, dict) else None
if not isinstance(routes, list) or any(not isinstance(url, str) for url in routes):
raise ValueError("protected-routes.yaml: нужен список строк routes")
if len(routes) != len(set(routes)):
raise ValueError("Повтор адреса в protected-routes.yaml")
for url in routes:
build.permalink_to_path(url)
if url not in expected:
errors.append(f"Потерян защищённый адрес: {url}")
elif expected[url]["noindex"]:
errors.append(f"Защищённая статья закрыта от индексации: {url}")
for url, page in expected.items():
target = build.DIST / build.permalink_to_path(url)
if not target.is_file():
errors.append(f"Нет файла страницы: {url}")
continue
parsed = parse_html(target.read_text(encoding="utf-8"))
parsed_pages[url] = parsed
if parsed.canonicals != [base_url + url]:
errors.append(f"Неверный canonical: {url}")
if ("noindex" in parsed.robots) != page["noindex"]:
errors.append(f"Несогласованный noindex: {url}")
wanted = parse_html(navigation.get(url, "")).neighbours
if parsed.neighbours != wanted:
errors.append(f"Неверные предыдущий/следующий урок: {url}")
for url, parsed in parsed_pages.items():
for tag, reference in parsed.references:
local = local_reference(url, reference, base_url)
if local is None:
continue
path, target, fragment = local
if not target.is_file():
errors.append(f"{url}: отсутствует {reference}")
continue
if tag == "a" and path in graph:
graph[url].add(path)
if fragment and path in parsed_pages:
if fragment not in parsed_pages[path].ids:
errors.append(f"{url}: отсутствует якорь {reference}")
reached = set()
pending = ["/index.html"]
while pending:
url = pending.pop()
if url not in reached:
reached.add(url)
pending.extend(graph.get(url, set()) - reached)
for url, page in expected.items():
if not page["noindex"] and url not in reached:
errors.append(f"Страница недостижима с главной по ссылкам: {url}")
tree = ET.parse(build.DIST / "sitemap.xml")
locations = [node.text for node in tree.findall(
".//{http://www.sitemaps.org/schemas/sitemap/0.9}loc"
)]
wanted_locations = {
base_url + url for url, page in expected.items() if not page["noindex"]
}
if len(locations) != len(set(locations)) or set(locations) != wanted_locations:
errors.append("Sitemap не совпадает с реестром индексируемых страниц")
robots = (build.DIST / "robots.txt").read_text(encoding="utf-8")
if f"Sitemap: {base_url}/sitemap.xml" not in robots.splitlines():
errors.append("robots.txt не указывает текущую карту сайта")
records = json.loads((build.DIST / "assets" / "search-index.json"
).read_text(encoding="utf-8"))
wanted_search = {document["permalink"]: document for document in documents}
if (not isinstance(records, list) or
any(not isinstance(item, dict) or not all(
isinstance(item.get(key), str) for key in ("title", "url", "text")
) for item in records)):
errors.append("Неверный формат поискового индекса")
else:
urls = [item["url"] for item in records]
if len(urls) != len(set(urls)) or set(urls) != set(wanted_search):
errors.append("Поиск не совпадает со списком публичных статей")
for item in records:
source = wanted_search.get(item["url"])
if source is not None and item["title"] != source["title"]:
errors.append(f'Неверное название в поиске: {item["url"]}')
if source is not None and item["text"] != build.article_text(source["body"]):
errors.append(f'Устаревший текст в поиске: {item["url"]}')
if errors:
for error in errors:
print(f"Ошибка: {error}")
raise SystemExit(1)
print(f"Проверено страниц: {len(expected)}; защищённых адресов: {len(routes)}.")
print("Ошибок ссылок, навигации, поиска и карты сайта не найдено.")
if __name__ == "__main__":
main()
Здесь HTMLParser рассматривает уже конечную страницу, вместе с общим шаблоном. Это принципиально отличается от извлечения текста в поисковый индекс: теперь нам нужны ссылки хедера, подключённые стили и навигация после статьи. Справочник ids позволяет обнаружить также переход на несуществующий якорь внутри известной страницы.
local_reference() разрешает относительную ссылку от адреса текущего документа. Например, на /articles/second.html ссылка ../assets/site.css ведёт к /assets/site.css. Папочный адрес /catalog/ преобразуется в файл /catalog/index.html. Внешние сайты, почта и другие схемы пропускаются: эта проверка не делает сетевых запросов и не оценивает доступность чужих ресурсов.
Граф ссылок нужен для другого условия. Наличие файла и запись в Sitemap ещё не означают, что до статьи можно добраться через сайт. Начинаем с главной страницы и последовательно рассматриваем обычные ссылки a. Индексируемый материал, до которого таким способом нельзя дойти, вызывает ошибку. Именно это защищает от «осиротевших» страниц при замене громоздкого старого оглавления новым каталогом.
Для пагинации мы сравниваем фактические ссылки с результатом build_navigation(). У первой статьи не должно быть prev, у последней — next, а средняя должна вести к обеим соседним страницам. Ошибка вставки общего шаблона поэтому обнаружится, даже если сами файлы всех трёх уроков существуют.
Ожидаемый результат и полезная ошибка
Когда будете проверять учебный проект, сначала создайте свежую папку результата, затем запустите отдельную проверку:
.venv/bin/python build.py
.venv/bin/python check.py
При согласованных исходниках ожидается вывод:
Проверено страниц: 8; защищённых адресов: 3.
Ошибок ссылок, навигации, поиска и карты сайта не найдено.
Рассмотрим ошибочный вариант. После сборки файл dist/my/legacy/first.php случайно отсутствует. При повторной проверке среди сообщений должно появиться:
Ошибка: Нет файла страницы: /my/legacy/first.php
Это не повод менять защищённый список: исходник существует, а выпуск повреждён. Повторная сборка должна восстановить файл. Иной случай — изменённый permalink исходника. Тогда исчезновение прежнего пути обнаруживается ещё в реестре, а последовательность first-course дополнительно указывает на отсутствующую статью.
Наш скрипт намеренно имеет ограниченную область. Он не разбирает srcset, CSS-фоновые изображения и динамические ссылки, которые создаёт JavaScript. Он не подтверждает HTTP-статусы, настройки MIME, работу формы поиска или отсутствие всех возможных дублей. Его результат означает выполнение перечисленных локальных условий. Следующий урок добавит серверные проверки после публикации отдельной тестовой копии.
Ручная задача GitLab CI
Те же две команды можно выполнить в отдельном учебном репозитории GitLab. Тогда проверка не зависит от пакетов, случайно установленных на компьютере автора. Перед добавлением файлов под контроль версий создайте в корне учебного проекта .gitignore:
.venv/
dist/
__pycache__/
*.pyc
backups/
original-download/
В репозитории нужны редактируемые исходники и настройки, а не локальное окружение и готовая папка выпуска: CI создаст их заново. В следующем уроке папки backups и original-download будут содержать настоящую резервную копию; её хранят отдельно. Собственный небольшой original/lesson.html из урока о переносе остаётся учебным исходником и этим списком не исключается. Пароли и ключи хостинга также не нужны проверке и не должны попадать в файлы репозитория. .gitignore действует на ещё не отслеживаемые файлы; добавленный раньше файл он сам не удаляет из истории.
Для проекта понадобится доступный runner, способный использовать выбранный образ Python. Создайте .gitlab-ci.yml:
image: python:3.11-slim
stages:
- verify
verify_site:
stage: verify
when: manual
allow_failure: false
script:
- python -m pip install -r requirements.txt
- python build.py
- python check.py
artifacts:
paths:
- dist/
expire_in: 7 days
Задача запускается вручную. allow_failure: false означает, что её ошибка считается ошибкой проверки, а не допустимым побочным результатом. При успешном выполнении GitLab сохранит dist как артефакт на семь дней. Синтаксис ручных задач и артефактов описан в официальной справке GitLab CI.
В этом файле отсутствует задача публикации: наличие успешной папки ещё не выбирает сервер, домен и момент переключения. Не нужны ни ключи хостинга, ни пароль FTP. Эти данные не относятся к проверке контента и не должны храниться в статьях или репозитории.
После добавления новых материалов расширяйте проверки по наблюдаемым ошибкам. Для каждого нового условия должно быть понятно, какое повреждение оно ловит и где автор его исправляет. Уже сейчас у нас есть существенный результат: старые пути, ресурсы, доступность страниц через навигацию и служебные списки можно сравнить автоматически до загрузки на хостинг.