Простой SEO-парсер на Python с нуля

Простой SEO-парсер на Python с нуля

SEO-парсер небольшой инструмент, который собирает с веб-страниц технические и содержательные данные: заголовок страницы, описание, заголовки, ссылки, изображения, служебные метаэлементы и другие признаки, важные для поисковой оптимизации.

Такой скрипт не заменяет полноценные платформы аудита, но отлично подходит для обучения, быстрой проверки небольшого сайта и автоматизации рутинных задач.

Разберём, как создать простой SEO-парсер на Python с нуля. Он будет загружать страницы, извлекать данные из HTML, проверять базовые ошибки, сохранять результаты в CSV и учитывать ограничения, которые нельзя игнорировать при работе с чужими сайтами.

Все примеры рассчитаны на тематику Интернета: блоги, каталоги сервисов, информационные проекты и небольшие корпоративные сайты.

Что должен уметь простой SEO-парсер

Перед написанием кода полезно определить границы проекта. Новички часто пытаются сразу сделать универсальный краулер, который понимает JavaScript, строит карту сайта, анализирует скорость, проверяет микроразметку и ещё рисует красивые графики.

В итоге получается громоздкая программа с большим количеством ошибок. Для первого варианта лучше оставить только понятные задачи, которые реально решить стандартными библиотеками Python и несколькими пакетами.

Минимальный SEO-парсер может проверять URL страницы, HTTP-код ответа, время загрузки, длину HTML, содержимое элемента title, description, заголовки h1, количество ссылок, изображения без атрибута alt, наличие canonical и директив robots.

Этого уже достаточно, чтобы увидеть значительную часть очевидных проблем на небольшом сайте.

  • Проверка доступности страницы и кода ответа.
  • Извлечение title и description.
  • Подсчёт символов в основных элементах.
  • Поиск h1 и выявление повторяющихся или отсутствующих заголовков.
  • Подсчёт внутренних и внешних ссылок.
  • Проверка изображений без alt.
  • Обнаружение canonical, noindex и ссылок с подозрительными атрибутами.
  • Сохранение результатов в удобный файл.

Важно понимать, что такие проверки являются технической диагностикой, а не оценкой качества сайта в целом.

Скрипт не определит, насколько полезен текст, не поймёт намерение пользователя и не заменит редактора. Зато он быстро обработает десятки или сотни страниц и покажет места, которые стоит изучить вручную.

ПоказательЧто показываетПочему важен
HTTP-кодРезультат обращения к серверуПомогает найти ошибки 4xx и 5xx
TitleОсновной заголовок документа в поисковой выдачеВлияет на понимание темы страницы и кликабельность
DescriptionКраткое описание страницыМожет использоваться поисковой системой в сниппете
H1Главный заголовок контентаПомогает определить основную тему документа
AltТекстовая альтернатива изображенияПолезен для доступности и понимания содержимого картинки

Подготовка Python и установка библиотек

Для проекта подойдёт Python версии 3.10 и новее. Проверить установленную версию можно командой python --version. В некоторых системах вместо неё используется python3 --version.

Рекомендуется создать отдельное виртуальное окружение: оно изолирует зависимости проекта и не смешивает их с библиотеками других программ.

Создадим папку проекта и установим необходимые пакеты. В примере используются requests для HTTP-запросов и Beautiful Soup для разбора HTML. Отдельный пакет lxml может ускорить обработку больших документов, но для первого варианта можно использовать стандартный парсер html.parser.

python -m venv.venv

Активация окружения в Windows выполняется командой .venv\Scripts\activate, а в Linux и macOS - командой source.venv/bin/activate. После активации установим зависимости:

pip install requests beautifulsoup4

Для сохранения таблиц в формате CSV дополнительных библиотек не требуется. Модуль csv входит в стандартную поставку Python. Это удобно: результат можно открыть в табличном редакторе, передать специалисту по SEO или загрузить в другую систему.

Структуру проекта лучше сделать простой. В корне могут находиться файл parser.py, папка с результатами и отдельный текстовый файл со списком URL. На раннем этапе не стоит дробить код на десятки модулей.

Сначала нужно добиться понятного рабочего результата, а рефакторинг выполнить после проверки логики.

seo_parser/
 parser.py
 urls.txt
 results/

Как правильно отправлять HTTP-запросы

Основой любого парсера является получение HTML. Для этого используется библиотека requests. Самый простой запрос выглядит так:

import requests

response = requests.get("https://example.com", timeout=10)
print(response.status_code)
print(response.text[:500])

Однако в реальной программе нельзя ограничиваться одним вызовом get. Сервер может не ответить, соединение может прерваться, адрес может быть некорректным, а сайт - вернуть неожиданный формат данных.

Поэтому запрос нужно поместить в конструкцию try except и отдельно обрабатывать сетевые ошибки.

import requests

def fetch_page(url):
 try:
 response = requests.get(
 url,
 timeout=10,
 headers={
 "User-Agent": "SimpleSeoParser/1.0"
 }
 )
 response.raise_for_status()
 return response
 except requests.RequestException as error:
 print(f"Ошибка запроса {url}: {error}")
 return None

Параметр timeout обязателен практически всегда. Без него программа может зависнуть на странице, сервер которой не отвечает. Значение десять секунд подходит для учебного инструмента, но в больших проектах его выбирают с учётом типа сайта и качества соединения.

Слишком маленький тайм-аут создаёт ложные ошибки, а слишком большой резко замедляет весь обход.

Заголовок User-Agent сообщает серверу, какая программа выполняет запрос. Не стоит маскироваться под браузер без причины. Честное имя вроде SimpleSeoParser/1.0 выглядит аккуратнее и помогает владельцу сайта понять источник нагрузки по логам.

Если сервер запрещает автоматический доступ, это ограничение нужно уважать.

Метод raise_for_status вызывает исключение для ответов с кодами 4xx и 5xx. Иногда полезно сохранять такие ответы в отчёте, поэтому можно не прерывать обработку, а записывать код и текст ошибки в отдельные поля. Для аудита важен сам факт, что страница вернула 404 или 500.

Группа кодаПримерСмысл
2xx200Страница успешно отдана
3xx301, 302Перенаправление
4xx404, 403Ошибка клиента или запрет доступа
5xx500, 503Ошибка на стороне сервера

Для более точного анализа стоит измерять время ответа. Это не равно полной скорости загрузки страницы в браузере, потому что requests не исполняет JavaScript и не загружает все ресурсы. Но показатель помогает заметить явно медленные серверные ответы.

import time
import requests

start = time.perf_counter()
response = requests.get(url, timeout=10)
elapsed = time.perf_counter() - start

print(f"Время ответа: {elapsed:.2f} сек.")

Разбор HTML с помощью Beautiful Soup

Получить HTML - только половина дела. Дальше нужно найти в нём нужные элементы. Beautiful Soup превращает строку документа в дерево, по которому удобно перемещаться. С его помощью можно искать теги, читать атрибуты и извлекать текст без сложных регулярных выражений.

from bs4 import BeautifulSoup

html = """

 

Полезные инструменты

""" soup = BeautifulSoup(html, "html.parser") print(soup.title.get_text(strip=True)) print(soup.h1.get_text(" ", strip=True))

Для поиска всех элементов используется метод find_all. Например, soup.find_all("h1") вернёт список всех заголовков первого уровня. Если элемента нет, обращение к soup.title даст значение None, поэтому перед вызовом get_text нужна проверка.

title_tag = soup.find("title")

if title_tag:
 title = title_tag.get_text(" ", strip=True)
else:
 title = ""

Метод get_text с разделителем пробелом полезнее простого text, когда внутри заголовка есть вложенные теги. Параметр strip=True убирает пробелы по краям. В отчёте лучше хранить очищенные значения, иначе в таблице появятся переносы строк и лишние пробелы.

HTML в Интернете не всегда идеален. На одной странице может отсутствовать закрывающий тег, на другой - встретиться несколько одинаковых служебных блоков. Beautiful Soup обычно пытается восстановить структуру, но результаты всё равно нужно проверять на реальных примерах.

Хороший парсер не должен падать из-за одной криво сверстанной страницы.

Извлечение title, description и заголовков

Title находится внутри head и обычно задаётся одноимённым тегом. Description чаще всего представлен метатегом с атрибутом name="description". Поскольку регистр атрибутов и структура HTML могут отличаться, удобнее искать тег по имени и проверять значение атрибута через словарь.

def get_meta_content(soup, name):
 tag = soup.find(
 "meta",
 attrs={"name": lambda value: value and value.lower() == name.lower()}
 )
 if tag:
 return tag.get("content", "").strip()
 return ""

def extract_basic_data(soup):
 title_tag = soup.find("title")
 title = title_tag.get_text(" ", strip=True) if title_tag else ""

 description = get_meta_content(soup, "description")

 h1_tags = soup.find_all("h1")
 h1_list = [
 tag.get_text(" ", strip=True)
 for tag in h1_tags
 if tag.get_text(" ", strip=True)
 ]

 return {
 "title": title,
 "description": description,
 "h1_count": len(h1_list),
 "h1_text": " | ".join(h1_list)
 }

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

Поэтому парсер должен отмечать потенциально короткие и длинные значения, а не объявлять их автоматически ошибочными.

ПроверкаВозможный сигналЧто сделать вручную
Title отсутствуетПустое значениеДобавить понятный заголовок страницы
Title слишком короткийМало смысловой информацииУточнить тему и тип страницы
Title слишком длинныйВероятно усечение сниппетаСократить без потери смысла
H1 отсутствуетНеясен главный заголовокПроверить структуру контента
Несколько H1Возможна неоднозначная структураПроверить шаблон и вложенные блоки

Проверка длины реализуется элементарно:

def classify_length(value, minimum, maximum):
 length = len(value)
 if length == 0:
 return "пусто"
 if length < minimum:
 return "короткое"
 if length > maximum:
 return "длинное"
 return "нормальный ориентир"

title_status = classify_length(title, 20, 70)
description_status = classify_length(description, 70, 180)

Числа 20, 70, 70 и 180 не являются законами поисковой оптимизации. Это технические пороги для первичного скрининга. В проекте для новостей title может быть коротким и при этом удачным, а у страницы каталога длинное описание иногда оправдано.

В отчёте полезно показывать и само значение, и его длину, чтобы специалист мог принять решение.

Заголовки h2 и h3 тоже стоит собирать. Они помогают быстро увидеть структуру статьи или карточки услуги. Например, если на странице интернет-сервиса внезапно отсутствуют подзаголовки при большом объёме текста, это повод проверить читаемость контента.

def extract_headings(soup):
 result = {}
 for level in ("h1", "h2", "h3"):
 tags = soup.find_all(level)
 result[level] = [
 tag.get_text(" ", strip=True)
 for tag in tags
 if tag.get_text(" ", strip=True)
 ]
 return result

Проверка ссылок и изображений

Ссылки важны не только для навигации, но и для распределения внутреннего веса страницы. Простой парсер может посчитать все ссылки, разделить их на внутренние и внешние и выделить ссылки без href.

Для этого нужно определить домен исходной страницы и сравнить его с доменом каждой ссылки.

from urllib.parse import urljoin, urlparse

def classify_links(soup, page_url):
 base_domain = urlparse(page_url).netloc.lower()
 internal = []
 external = []
 broken_attrs = 0

 for tag in soup.find_all("a"):
 href = tag.get("href", "").strip()

 if not href:
 broken_attrs += 1
 continue

 absolute_url = urljoin(page_url, href)
 parsed = urlparse(absolute_url)

 if parsed.scheme not in ("http", "https"):
 continue

 if parsed.netloc.lower() == base_domain:
 internal.append(absolute_url)
 else:
 external.append(absolute_url)

 return {
 "internal_links": len(internal),
 "external_links": len(external),
 "empty_links": broken_attrs
 }

URL с якорем, например ссылка на блок внутри текущей страницы, не является проблемой сама по себе. Ссылки mailto и tel тоже не нужно считать внешними веб-страницами.

Именно поэтому в примере сначала используется urljoin, а затем проверяется схема. Такая мелочь заметно повышает качество отчёта.

Проверять коды всех найденных ссылок отдельными запросами можно, но это уже серьёзно увеличивает нагрузку. Если на странице сто ссылок, а в проекте тысяча страниц, количество запросов быстро вырастет до ста тысяч.

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

С изображениями ситуация похожая. Полезно узнать количество картинок, сколько из них имеют alt и какие файлы подключены. Альтернативный текст не должен быть бессмысленным набором ключевых слов.

Его задача - кратко объяснить содержание изображения пользователю, который не видит картинку или отключил её загрузку.

def analyze_images(soup):
 images = soup.find_all("img")
 without_alt = []

 for image in images:
 alt = image.get("alt")
 if alt is None or not alt.strip():
 without_alt.append(image.get("src", ""))

 return {
 "images_count": len(images),
 "images_without_alt": len(without_alt),
 "images_without_alt_list": " | ".join(without_alt)
 }

Не каждое изображение требует описательного alt. Декоративным элементам иногда подходит пустой атрибут alt="", потому что они не несут смысловой нагрузки.

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

Canonical, robots и базовые технические сигналы

На сайте может существовать несколько URL с одинаковым или близким содержимым. Причинами бывают параметры сортировки, фильтры, версии страниц с завершающим слешем и другие особенности CMS. Элемент canonical подсказывает поисковым системам предпочтительный адрес.

Простой парсер может проверить, присутствует ли он и как записано его значение.

def get_canonical(soup, page_url):
 tag = soup.find("link", rel=lambda value: value and "canonical" in value)
 if not tag:
 return ""

 href = tag.get("href", "").strip()
 return urljoin(page_url, href) if href else ""

Наличие canonical ещё не означает, что он настроен правильно. Адрес может вести на другую страницу, содержать ошибку или указывать на несуществующий URL.

Поэтому в отчёте полезно хранить абсолютное значение canonical и сравнивать его с текущим адресом. Для страниц, которые должны ссылаться сами на себя, расхождение требует проверки.

Директива robots может находиться в метатеге robots. Наиболее известный вариант - noindex. Если такая директива случайно попала на коммерческую страницу или важную статью, она способна закрыть документ от индексации.

Но у служебных страниц, фильтров и результатов поиска подобная директива может быть намеренной.

def get_robots_directive(soup):
 tag = soup.find(
 "meta",
 attrs={"name": lambda value: value and value.lower() == "robots"}
 )
 if not tag:
 return ""

 return tag.get("content", "").strip().lower()

def has_noindex(robots_value):
 return "noindex" in robots_value

Есть важное уточнение: метатег robots и файл robots.txt - разные механизмы. Первый находится внутри HTML конкретной страницы, а второй размещается по адресу домена и задаёт правила для роботов на уровне путей.

Учебный парсер может дополнительно загрузить robots.txt, но нельзя считать его простым списком запретов без учёта синтаксиса и правил конкретного User-agent.

Также можно проверить наличие hreflang, Open Graph и структурированных данных. Для базового проекта достаточно посчитать соответствующие теги и вывести их содержимое.

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

Обход нескольких страниц и защита от повторов

Парсинг одной страницы полезен для отладки, но настоящий SEO-аудит начинается с обхода набора URL. Самый безопасный вариант для первой версии - читать адреса из файла urls.txt.

В нём каждый URL располагается на отдельной строке, пустые строки игнорируются, а строки, начинающиеся с символа служебного комментария, можно пропускать.

def read_urls(filename):
 urls = []

 with open(filename, "r", encoding="utf-8") as file:
 for line in file:
 url = line.strip()

 if not url:
 continue
 if url.startswith(";"):
 continue

 urls.append(url)

 return urls

Если требуется перейти от списка к мини-краулеру, нужно извлекать внутренние ссылки и добавлять их в очередь.

Для этого используется множество visited, в котором хранятся уже обработанные адреса. Без такого множества сайт с циклической навигацией может привести к бесконечному обходу.

from collections import deque
from urllib.parse import urlparse

def crawl(start_url, limit=50):
 queue = deque([start_url])
 visited = set()
 result = []

 start_domain = urlparse(start_url).netloc.lower()

 while queue and len(visited) < limit:
 current_url = queue.popleft()

 if current_url in visited:
 continue

 visited.add(current_url)
 response = fetch_page(current_url)

 if response is None:
 continue

 result.append((current_url, response.text))

 soup = BeautifulSoup(response.text, "html.parser")

 for link in soup.find_all("a", href=True):
 next_url = urljoin(current_url, link["href"])
 parsed = urlparse(next_url)

 if parsed.scheme in ("http", "https"):
 if parsed.netloc.lower() == start_domain:
 if next_url not in visited:
 queue.append(next_url)

 return result

Лимит страниц обязателен. Даже небольшой интернет-сайт может генерировать бесконечное количество URL из-за параметров фильтрации, календарей, сортировки и поисковых форм. Для первой проверки достаточно 30–100 страниц.

После этого можно добавить ограничение глубины, исключение параметров и нормализацию адресов.

Нормализация URL позволяет не считать разными адресами варианты, которые фактически ведут на одну страницу. Минимально можно удалять фрагмент после символа якоря. С параметрами нужно быть осторожнее: utm-метки обычно не меняют содержание, а параметры фильтров могут создавать отдельные полезные документы.

Нельзя бездумно удалять всё после вопросительного знака.

from urllib.parse import urldefrag

def normalize_url(url):
 clean_url, fragment = urldefrag(url)
 return clean_url

Сбор результатов и создание CSV-отчёта

Результаты лучше хранить не в виде множества print в консоли, а в словарях с одинаковыми полями. Тогда их легко сохранить в CSV, JSON или базу данных. Каждая строка отчёта должна соответствовать одной обработанной странице, даже если запрос завершился ошибкой.

Это позволяет увидеть полный охват проверки.

import csv

FIELDS = [
 "url",
 "status_code",
 "response_time",
 "title",
 "title_length",
 "description",
 "description_length",
 "h1_count",
 "h1_text",
 "internal_links",
 "external_links",
 "images_count",
 "images_without_alt",
 "canonical",
 "robots",
 "error"
]

def save_csv(rows, filename):
 with open(filename, "w", newline="", encoding="utf-8-sig") as file:
 writer = csv.DictWriter(file, fieldnames=FIELDS)
 writer.writeheader()

 for row in rows:
 writer.writerow({
 field: row.get(field, "")
 for field in FIELDS
 })

Кодировка utf-8-sig выбрана ради совместимости с некоторыми версиями табличных редакторов. В обычном Python-проекте достаточно UTF-8, но при передаче отчёта коллегам такой вариант часто экономит время: кириллица открывается корректнее и не превращается в нечитаемые символы.

В отчёт стоит добавлять не только сырые данные, но и понятные статусы. Например, поле title_issue может принимать значения "нет", "короткий", "длинный" или "нормальный ориентир". Однако не нужно перегружать файл десятками спорных автоматических оценок.

Лучше иметь несколько надёжных сигналов, чем красивую таблицу, наполненную ложными тревогами.

def make_row(url, response, soup, elapsed):
 basic = extract_basic_data(soup)
 links = classify_links(soup, url)
 images = analyze_images(soup)
 robots = get_robots_directive(soup)

 return {
 "url": url,
 "status_code": response.status_code,
 "response_time": round(elapsed, 3),
 "title": basic["title"],
 "title_length": len(basic["title"]),
 "description": basic["description"],
 "description_length": len(basic["description"]),
 "h1_count": basic["h1_count"],
 "h1_text": basic["h1_text"],
 "internal_links": links["internal_links"],
 "external_links": links["external_links"],
 "images_count": images["images_count"],
 "images_without_alt": images["images_without_alt"],
 "canonical": get_canonical(soup, url),
 "robots": robots,
 "error": ""
 }

Для крупного отчёта полезно дополнительно сохранить JSON. CSV хорошо подходит для фильтрации и сортировки, а JSON сохраняет вложенную структуру: список заголовков, найденные URL и подробности ошибок. Но для поставленной задачи CSV остаётся наиболее понятным форматом.

Полный рабочий пример программы

Теперь объединим основные части в один компактный скрипт. Он читает URL из файла, отправляет запрос, разбирает HTML, собирает SEO-поля и записывает строки в results/report.csv. Такой код не претендует на промышленную надёжность, зато его легко читать и расширять.

import csv
import time
import requests

from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse, urldefrag

FIELDS = [
 "url", "status_code", "response_time", "title", "title_length",
 "description", "description_length", "h1_count", "h1_text",
 "internal_links", "external_links", "empty_links",
 "images_count", "images_without_alt", "canonical", "robots", "error"
]

def read_urls(filename):
 with open(filename, "r", encoding="utf-8") as file:
 return [
 line.strip()
 for line in file
 if line.strip() and not line.strip().startswith(";")
 ]

def fetch(url):
 try:
 start = time.perf_counter()

 response = requests.get(
 url,
 timeout=10,
 headers={"User-Agent": "SimpleSeoParser/1.0"}
 )

 elapsed = time.perf_counter() - start
 return response, elapsed, ""

 except requests.RequestException as error:
 return None, 0, str(error)

def meta_content(soup, name):
 tag = soup.find(
 "meta",
 attrs={"name": lambda value: value and value.lower() == name.lower()}
 )
 return tag.get("content", "").strip() if tag else ""

def analyze(url, response, elapsed):
 soup = BeautifulSoup(response.text, "html.parser")

 title_tag = soup.find("title")
 title = title_tag.get_text(" ", strip=True) if title_tag else ""

 description = meta_content(soup, "description")
 robots = meta_content(soup, "robots")

 h1_list = [
 tag.get_text(" ", strip=True)
 for tag in soup.find_all("h1")
 if tag.get_text(" ", strip=True)
 ]

 current_domain = urlparse(url).netloc.lower()
 internal_count = 0
 external_count = 0
 empty_links = 0

 for tag in soup.find_all("a"):
 href = tag.get("href", "").strip()

 if not href:
 empty_links += 1
 continue

 absolute = urljoin(url, href)
 parsed = urlparse(absolute)

 if parsed.scheme not in ("http", "https"):
 continue

 if parsed.netloc.lower() == current_domain:
 internal_count += 1
 else:
 external_count += 1

 images = soup.find_all("img")
 images_without_alt = sum(
 1 for image in images
 if image.get("alt") is None or not image.get("alt").strip()
 )

 canonical_tag = soup.find(
 "link",
 rel=lambda value: value and "canonical" in value
 )
 canonical = ""
 if canonical_tag and canonical_tag.get("href"):
 canonical = urljoin(url, canonical_tag["href"].strip())

 return {
 "url": url,
 "status_code": response.status_code,
 "response_time": round(elapsed, 3),
 "title": title,
 "title_length": len(title),
 "description": description,
 "description_length": len(description),
 "h1_count": len(h1_list),
 "h1_text": " | ".join(h1_list),
 "internal_links": internal_count,
 "external_links": external_count,
 "empty_links": empty_links,
 "images_count": len(images),
 "images_without_alt": images_without_alt,
 "canonical": canonical,
 "robots": robots,
 "error": ""
 }

def save_report(rows, filename):
 with open(filename, "w", newline="", encoding="utf-8-sig") as file:
 writer = csv.DictWriter(file, fieldnames=FIELDS)
 writer.writeheader()
 writer.writerows(rows)

def main():
 urls = read_urls("urls.txt")
 rows = []

 for url in urls:
 print(f"Проверка: {url}")
 response, elapsed, error = fetch(url)

 if response is None:
 rows.append({
 "url": url,
 "error": error
 })
 continue

 try:
 rows.append(analyze(url, response, elapsed))
 except Exception as error:
 rows.append({
 "url": url,
 "status_code": response.status_code,
 "error": f"Ошибка разбора: {error}"
 })

 time.sleep(1)

 save_report(rows, "results/report.csv")
 print("Готово. Отчёт сохранён в results/report.csv")

if name == "main":
 main()

В этом варианте между запросами стоит пауза в одну секунду. Для десяти страниц она почти незаметна, зато снижает интенсивность обращений к серверу. Если обрабатывать большой проект, задержку следует выбирать аккуратно, а количество параллельных запросов ограничивать.

Высокая скорость не должна превращать полезный инструмент в источник лишней нагрузки.

Перед запуском создайте urls.txt, например с адресами главной страницы, нескольких статей и карточек интернет-сервисов. Затем выполните команду python parser.py. Если папка results не создана, её нужно создать заранее или добавить в программу автоматическое создание через модуль os.

Типичные ошибки и улучшение качества парсера

Первая распространённая ошибка - использование регулярных выражений для полного разбора HTML. Регулярные выражения могут пригодиться для простого поиска отдельных шаблонов, но HTML допускает вложенность, переносы строк, разные кавычки и нестандартное форматирование.

Парсер HTML надёжнее и лучше объясняет намерение кода.

Вторая проблема - отсутствие обработки кодировки. Requests обычно определяет encoding по заголовкам ответа, но иногда сервер сообщает её неправильно. При необходимости можно проверить response.apparent_encoding, однако это дополнительный и более медленный анализ.

На большинстве современных сайтов UTF-8 определяется корректно.

Третья ошибка - абсолютное доверие к статусу 200. Страница может вернуть код 200, но содержать сообщение "товар не найден", заглушку, страницу блокировки или форму капчи.

Поэтому SEO-парсер должен сохранять title и небольшой фрагмент текста для спорных случаев, а результаты автоматически проверять выборочно.

Отдельная категория проблем связана с JavaScript. Requests получает исходный HTML, который сервер отправил до выполнения скриптов. Если заголовки, ссылки и основной текст формируются только в браузере, простой парсер их не увидит.

Для таких проектов применяют Playwright или Selenium, но это существенно повышает требования к памяти, времени и настройке окружения.

Не стоит сразу добавлять многопоточность. Параллельные запросы ускоряют обход, но усложняют обработку ошибок, увеличивают нагрузку и могут привести к блокировке.

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

Полезная функция - повторять запрос только для временных ошибок. Например, при 503 можно выполнить ещё одну попытку через несколько секунд, а при 404 повтор обычно бессмысленен.

Но даже повторные запросы следует ограничивать: двух или трёх попыток достаточно для учебного инструмента.

def fetch_with_retry(url, attempts=2):
 last_error = ""

 for attempt in range(attempts):
 response, elapsed, error = fetch(url)

 if response is not None:
 return response, elapsed, ""

 last_error = error
 time.sleep(2)

 return None, 0, last_error

Ещё одно улучшение - логирование. Файл журнала помогает понять, на каком URL программа остановилась, сколько страниц обработано и почему возникла ошибка. Для небольшого скрипта хватит модуля logging, который входит в стандартную библиотеку.

Этика, robots.txt и безопасность работы

Парсинг открытой веб-страницы технически возможен, но это не означает автоматическое право создавать любую нагрузку. Перед обходом чужого сайта проверьте правила использования, robots.txt и ограничения владельца.

Даже если robots.txt не запрещает конкретный путь, разумно соблюдать умеренную скорость и не запускать десятки потоков без необходимости.

Не отправляйте формы, не создавайте аккаунты, не перебирайте пароли и не пытайтесь обходить защиту. SEO-парсер должен читать публичные документы, а не взаимодействовать с закрытой частью сайта. Если сервер возвращает капчу или явный запрет, корректное действие - остановить обход.

Обрабатывайте только те URL, которые относятся к нужному домену. Это особенно важно для краулера, который собирает ссылки автоматически. В противном случае одна внешняя ссылка может увести программу на огромный сторонний сайт.

Проверка netloc и лимит страниц являются базовыми мерами безопасности.

Нельзя бездумно сохранять персональные данные из HTML. На страницах могут встречаться имена, адреса электронной почты, телефоны и другие сведения. Для SEO-отчёта обычно достаточно технических полей. Чем меньше лишнего контента хранится, тем ниже риск случайной утечки при передаче CSV.

Практический режим работы можно организовать так:

  • Сначала проверить собственный сайт на десяти страницах.
  • Проверить, что программа не уходит на внешние домены.
  • Установить лимит количества страниц.
  • Добавить задержку между запросами.
  • Сохранять ошибки, а не повторять их бесконечно.
  • Остановить скрипт при появлении капчи или запрета доступа.
  • Хранить отчёты и исходные данные с учётом требований безопасности.

Эти правила не мешают анализу, а делают инструмент предсказуемым. Хороший парсер - не тот, который скачивает всё максимально быстро, а тот, который даёт полезный результат без вреда для инфраструктуры сайта.

Как развивать проект дальше

После базовой версии можно добавить проверку sitemap.xml. Карта сайта удобна тем, что содержит список URL, которые владелец считает важными. Сравнение адресов из sitemap с реально доступными страницами помогает найти 404, редиректы и документы, случайно исключённые из карты.

Следующее направление - анализ дубликатов title и description. Для этого нужно собрать значения в словари, где ключом является текст, а значением - список URL. Если один title встречается на десяти страницах, отчёт должен показать все адреса, а не только количество совпадений.

from collections import defaultdict

def find_duplicates(rows, field):
 groups = defaultdict(list)

 for row in rows:
 value = row.get(field, "").strip()
 if value:
 groups[value].append(row["url"])

 return {
 value: urls
 for value, urls in groups.items()
 if len(urls) > 1
 }

Можно добавить анализ цепочек редиректов, но для него потребуется отдельная настройка requests. Параметр allow_redirects по умолчанию разрешает переходы, однако для аудита иногда нужно узнать исходный статус и конечный URL.

Сохраняйте историю перенаправлений, чтобы видеть лишние звенья.

Ещё один полезный модуль - проверка заголовков HTTP: cache-control, content-type, x-robots-tag и content-language. Например, директива X-Robots-Tag может запретить индексацию на уровне HTTP-ответа, даже если в HTML нет meta robots. Такие проверки уже ближе к техническому аудиту сервера.

Для современного Интернет-проекта можно подключить Playwright и сделать два режима: быстрый HTML-анализ через requests и расширенный браузерный анализ для страниц, где контент появляется после выполнения JavaScript. Разделение режимов экономит ресурсы: не нужно запускать полноценный браузер для каждой простой статьи.

Наконец, результаты можно визуализировать. CSV достаточно для начала, но график распределения кодов ответа, диаграмма длины title и список страниц без description помогают быстрее увидеть масштаб проблемы.

Для этого подойдут pandas и matplotlib, хотя добавлять их стоит только после того, как основной сбор данных работает стабильно.

Главное развитие - не количество функций, а качество проверок. Каждая новая проверка должна отвечать на конкретный вопрос: какие страницы недоступны, где отсутствует важный элемент, какие шаблоны создают дубликаты, почему краулер не видит часть контента.

Если ответ не помогает принять решение, такая функция, скорее всего, пока не нужна.

В результате простой SEO-парсер на Python превращается в практичный инструмент для регулярного контроля сайта. Он показывает техническое состояние страниц, ускоряет поиск повторяющихся проблем и помогает понять, как устроен HTML проекта. Начинать лучше с малого: аккуратные запросы, понятный разбор документа, ограниченный набор проверок и честный CSV-отчёт.

После этого можно постепенно добавлять sitemap, редиректы, дубликаты, JavaScript-анализ и визуализацию, не превращая код в непонятный комбайн.

Короткие вопросы и ответы

Можно ли использовать такой парсер для большого сайта? Да, но базовую версию нужно доработать: добавить очередь, ограничение скорости, кэш, повторные попытки, нормализацию URL и хранение результатов в базе данных. Для миллионов страниц потребуется отдельная архитектура.

Почему requests не видит текст страницы? Вероятнее всего, контент формируется JavaScript после загрузки. В этом случае нужен браузерный инструмент вроде Playwright либо серверный API, из которого сайт получает данные.

Достаточно ли проверить title и description для SEO-аудита? Нет. Это полезный первый слой, но полноценная проверка включает доступность, индексацию, каноникализацию, ссылки, контент, скорость, мобильную адаптацию и качество самих страниц.