Разработка краулера для сбора структуры сайтов конкурентов
Анализ структуры конкурирующих сайтов вручную — потеря времени при любом масштабе. Даже для среднего проекта в нише это 50–200 URL, которые надо не просто собрать, но и разобрать по уровням вложенности, якорям, мета-данным, схемам разметки. Написанный краулер решает это за минуты и воспроизводится при каждом ребрендинге конкурента.
Стек и подход
Два рабочих варианта: Python + Scrapy/Playwright для сложных SPA с ленивой загрузкой, Node.js + Puppeteer/Cheerio для большинства стандартных сайтов. В задачах, где нет динамического JS-рендеринга, хватает HTTP-клиента с HTML-парсером — быстрее в 5–10 раз, проще в деплое.
Минимальная Python-реализация на основе requests + lxml:
import requests
from lxml import html
from urllib.parse import urljoin, urlparse
from collections import deque
import time
class SiteStructureCrawler:
def __init__(self, base_url: str, max_depth: int = 4, delay: float = 1.0):
self.base_url = base_url
self.domain = urlparse(base_url).netloc
self.max_depth = max_depth
self.delay = delay
self.visited: dict[str, dict] = {}
self.queue: deque = deque([(base_url, 0)])
def crawl(self):
session = requests.Session()
session.headers['User-Agent'] = (
'Mozilla/5.0 (compatible; SiteAnalyzer/1.0; +https://example.com/bot)'
)
while self.queue:
url, depth = self.queue.popleft()
if url in self.visited or depth > self.max_depth:
continue
try:
resp = session.get(url, timeout=10, allow_redirects=True)
resp.raise_for_status()
except requests.RequestException as e:
self.visited[url] = {'error': str(e), 'depth': depth}
continue
doc = html.fromstring(resp.content)
doc.make_links_absolute(url)
title = doc.findtext('.//title') or ''
h1 = [h.text_content().strip() for h in doc.cssselect('h1')]
meta_desc_el = doc.cssselect('meta[name="description"]')
meta_desc = meta_desc_el[0].get('content', '') if meta_desc_el else ''
canonical_el = doc.cssselect('link[rel="canonical"]')
canonical = canonical_el[0].get('href', '') if canonical_el else ''
noindex = bool(doc.cssselect('meta[name="robots"][content*="noindex"]'))
links = []
for a in doc.cssselect('a[href]'):
href = a.get('href', '').strip()
parsed = urlparse(href)
if parsed.netloc == self.domain and href not in self.visited:
links.append(href)
if depth + 1 <= self.max_depth:
self.queue.append((href, depth + 1))
self.visited[url] = {
'depth': depth,
'status': resp.status_code,
'title': title.strip(),
'h1': h1,
'meta_description': meta_desc,
'canonical': canonical,
'noindex': noindex,
'internal_links': links,
'content_type': resp.headers.get('Content-Type', ''),
}
time.sleep(self.delay)
return self.visited
Сбор дополнительных сигналов
Помимо базовой структуры, полезно собирать:
Schema.org разметка — тип страницы (Article, Product, BreadcrumbList, FAQPage), наличие structured data говорит о зрелости SEO-команды конкурента:
import json
def extract_schema(doc):
schemas = []
for script in doc.cssselect('script[type="application/ld+json"]'):
try:
data = json.loads(script.text_content())
schemas.append(data)
except json.JSONDecodeError:
pass
return schemas
Глубина заголовков — иерархия H1–H6 на страницах, типичные паттерны для категорийных страниц конкурента:
def heading_tree(doc):
headings = []
for tag in ['h1', 'h2', 'h3', 'h4']:
for el in doc.cssselect(tag):
headings.append({'tag': tag, 'text': el.text_content().strip()})
return headings
Хлебные крошки — как конкурент строит навигацию и как это отражается в URL-структуре.
Работа с JavaScript-рендерингом
Если сайт конкурента — SPA (React/Vue/Angular) или использует lazy-load для основного контента, обычный HTTP-краулер вернёт пустые страницы. Здесь нужен headless-браузер:
from playwright.sync_api import sync_playwright
def crawl_spa_page(url: str) -> dict:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url, wait_until='networkidle', timeout=30000)
title = page.title()
h1_elements = page.query_selector_all('h1')
h1_texts = [el.inner_text() for el in h1_elements]
# Сбор всех ссылок после рендеринга
links = page.eval_on_selector_all(
'a[href]',
'els => els.map(e => e.href)'
)
browser.close()
return {'title': title, 'h1': h1_texts, 'links': links}
Playwright добавляет ~2–5 секунд на страницу против 0.3–0.8 секунды для обычного HTTP. При краулинге 500+ страниц это ощутимо — используется только там, где без него не обойтись.
Хранение и анализ результатов
Собранная структура экспортируется в несколько форматов в зависимости от задачи:
JSON — для дальнейшей программной обработки:
import json
with open('competitor_structure.json', 'w', encoding='utf-8') as f:
json.dump(crawler.visited, f, ensure_ascii=False, indent=2)
CSV — для анализа в Excel/Google Sheets:
import csv
fieldnames = ['url', 'depth', 'status', 'title', 'meta_description', 'h1', 'noindex', 'canonical']
with open('competitor_structure.csv', 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction='ignore')
writer.writeheader()
for url, data in crawler.visited.items():
row = {'url': url, **data}
if isinstance(row.get('h1'), list):
row['h1'] = ' | '.join(row['h1'])
writer.writerow(row)
SQLite — если нужно сравнивать несколько конкурентов или отслеживать изменения во времени:
import sqlite3
conn = sqlite3.connect('competitors.db')
conn.execute('''CREATE TABLE IF NOT EXISTS pages (
url TEXT PRIMARY KEY,
domain TEXT,
depth INTEGER,
status INTEGER,
title TEXT,
meta_description TEXT,
noindex BOOLEAN,
crawled_at DATETIME DEFAULT CURRENT_TIMESTAMP
)''')
Регулярный краулинг и diff
Разовый сбор данных быстро устаревает. Конкуренты меняют структуру, добавляют разделы, переформатируют заголовки. Полезно настроить автоматический запуск раз в неделю/месяц и сравнивать результаты:
def diff_structures(old: dict, new: dict) -> dict:
added = {url: data for url, data in new.items() if url not in old}
removed = {url: data for url, data in old.items() if url not in new}
changed = {}
for url in old:
if url in new:
if old[url].get('title') != new[url].get('title'):
changed[url] = {
'old_title': old[url].get('title'),
'new_title': new[url].get('title'),
}
return {'added': added, 'removed': removed, 'changed': changed}
Этика и ограничения
Краулер должен уважать robots.txt. Библиотека robotparser из стандартной библиотеки Python:
from urllib.robotparser import RobotFileParser
rp = RobotFileParser()
rp.set_url(f'{base_url}/robots.txt')
rp.read()
if not rp.can_fetch('*', url):
continue # пропускаем запрещённые пути
Задержка между запросами (delay) — обязательный параметр. Минимально разумное значение — 1 секунда. Для крупных сайтов лучше 2–3 секунды, чтобы не нагружать сервер и не попасть под бан по IP. Если краулинг нужен регулярно — имеет смысл ротация User-Agent и при необходимости прокси-пул.
Сроки
Базовый краулер (HTTP, без SPA) с экспортом в CSV/JSON — 1–2 рабочих дня. С поддержкой JavaScript-рендеринга, сбором Schema.org, дифф-сравнением и SQLite-хранилищем — 3–4 дня. Интеграция с планировщиком (cron/Airflow) и уведомлениями при изменениях — ещё 1–2 дня.







