Сервисы анализа текстов конкурентов выдают список «LSI-слов» и редко объясняют, как он посчитан. Посчитаем сами: скачаем тексты ТОП-20, приведём слова к начальной форме и оставим те, что есть у многих конкурентов и редко встречаются в языке вообще. На выходе — скрипт на 95 строк и таблица: слово, у скольких конкурентов оно есть и сколько раз вписать его в ваш текст.
Что понадобится
Python 3.10 или новее и четыре библиотеки. Библиотека trafilatura вытаскивает из страницы основной текст без меню и подвала, razdel режет текст на слова, pymorphy3 приводит слова к начальной форме, а wordfreq знает, как часто слово встречается в русском языке вообще.
pip install trafilatura pymorphy3 razdel wordfreq
Ещё нужен файл urls.txt с адресами из ТОПа по вашему запросу, по одному в строке. Проверено 28 сентября 2026 года: Ubuntu 24.04 (WSL2), Python 3.12.3, trafilatura 2.0.0, pymorphy3 2.0.6, razdel 0.5.0, wordfreq 3.1.1.
Скрипт целиком, ниже он разобран по шагам:
# pip install trafilatura pymorphy3 razdel wordfreq
import pathlib
import re
import statistics
import sys
from collections import Counter
import pymorphy3
import trafilatura
from razdel import tokenize
from wordfreq import word_frequency
morph = pymorphy3.MorphAnalyzer()
SKIP_POS = {"PREP", "CONJ", "PRCL", "INTJ", "NPRO", "NUMR"} # служебные слова и местоимения
WORD = re.compile(r"[а-яё]{3,}(?:-[а-яё]+)*|[a-z]{3,}")
PARSES = {} # лемма -> разбор, из которого она получена
def read(source):
"""Список URL в файле или каталог с готовыми .txt."""
path = pathlib.Path(source)
if path.is_dir():
return [f.read_text(encoding="utf-8") for f in sorted(path.glob("*.txt"))]
texts = []
for url in path.read_text(encoding="utf-8").split():
html = trafilatura.fetch_url(url)
text = trafilatura.extract(html) if html else None
if text:
texts.append(text)
else:
print("не скачалось:", url, file=sys.stderr)
return texts
def analyze(text):
"""Леммы значимых слов и общее число слов в тексте."""
words = [t.text for t in tokenize(text.lower()) if WORD.fullmatch(t.text)]
lemmas = Counter()
for word in words:
parse = morph.parse(word)[0]
if parse.tag.POS in SKIP_POS:
continue
PARSES.setdefault(parse.normal_form, parse)
lemmas[parse.normal_form] += 1
return lemmas, len(words)
def language_rate(lemma):
"""Сколько раз на тысячу слов лемма встречается в русском языке вообще — по всем формам."""
parse = PARSES[lemma]
forms = {f.word for f in parse.lexeme} if parse.tag.POS else {lemma}
return 1000 * sum(word_frequency(form, "ru") for form in forms)
docs = [analyze(text) for text in read(sys.argv[1])]
volume = int(sys.argv[2]) if len(sys.argv) > 2 else 1500 # объём будущего текста, слов
n = len(docs)
df = Counter(lemma for lemmas, _ in docs for lemma in lemmas)
rows = []
for lemma, k in df.items():
if k < 3:
continue # слово у одной-двух страниц — их особенность, а не тема
rates = [lemmas[lemma] / total * 1000 for lemmas, total in docs if lemma in lemmas]
q1, median, q3 = statistics.quantiles(rates, n=4, method="inclusive")
lift = median / max(language_rate(lemma), 0.001)
if lift < 20:
continue # встречается почти так же часто, как в языке вообще
low = max(1, round(q1 * volume / 1000))
rows.append((lemma, k, median, lift, low, max(low, round(q3 * volume / 1000))))
rows.sort(key=lambda r: (-r[1], -r[3]))
last = f"вписать в {volume} слов"
columns = [("слово", 16, "<"), ("у конкурентов", 13, ">"), ("на 1000 слов", 12, ">"),
("чаще, чем в языке", 17, ">"), (last, len(last), ">")]
def line(cells):
return " | ".join(f"{cell:{align}{width}}" for cell, (_, width, align) in zip(cells, columns))
print(f"документов: {n}, объём текста: {volume} слов")
required = [r for r in rows if r[1] >= n / 2]
extra = [r for r in rows if r[1] < n / 2]
groups = [
(f"Обязательные (всего {len(required)}): есть у половины конкурентов и больше", required, 20),
(f"Дополнительные (всего {len(extra)}): есть у меньшинства, но минимум у трёх", extra, 10),
]
for title, group, limit in groups:
print(f"\n{title}")
print(line([name for name, _, _ in columns]))
print("-+-".join("-" * width for _, width, _ in columns))
for lemma, k, median, lift, low, high in group[:limit]:
span = f"{low}–{high}" if high > low else f"{low}"
print(line([lemma, f"{k} из {n}", f"{median:.1f}", f"{lift:.0f}×", span]))
Запуск — python topic_vocab.py urls.txt 1500, где 1500 — объём текста, который вы собираетесь писать, в словах.
Шаг 1. Тексты ТОПа своего типа
Адреса берите из выдачи по своему запросу и в своём регионе, руками или через любой XML-сервис выдачи. Главное здесь — отбор, а не скачивание. Если вы пишете статью, выкиньте из списка каталоги, карточки товаров и маркетплейсы: у них другой словарь («в корзину», «доставка», «в наличии»), и в общей таблице он перемешается со словарём статей.
Функция read скачивает каждую страницу и отдаёт её trafilatura, чтобы достать основной текст. Часть сайтов отвечает роботам отказом — скрипт напишет «не скачалось» и пойдёт дальше. Если таких адресов много, сохраните тексты вручную в файлы .txt и передайте скрипту каталог вместо списка ссылок: read понимает оба варианта. Только держите в каталоге одни тексты — файл со ссылками, положенный туда же, скрипт посчитает ещё одним конкурентом. Мы на этом попались: вместо двадцати документов получилось двадцать два.
Шаг 2. Леммы вместо словоформ
«Шина», «шины» и «шинами» — одно слово, и считать их надо вместе. Функция analyze режет текст на слова, отбрасывает предлоги, союзы, частицы и местоимения и приводит остальное к начальной форме. Заодно она считает все слова текста: частоту дальше берём на тысячу слов, иначе длинная страница задавит короткие.
Здесь мы наступили на грабли, которые стоит показать. Чтобы понять, насколько слово редкое, нужны все его формы, а начальная форма не говорит, какой из омонимов имелся в виду. «Может» разбирается как глагол «мочь», но «мочь» — это ещё и существительное («изо всей мочи»). Первая версия скрипта брала формы существительного, находила их в языке редкими, и «мочь» попала в словарь темы с отрывом в 717 раз. Поэтому словарь PARSES запоминает, из какого разбора получена лемма, и формы берутся из него. С исправлением отрыв у «мочь» — 1,3 раза, и она отсеивается.
Шаг 3. Доля конкурентов и отрыв от языка
Для каждой леммы считаем три числа. Первое — у скольких конкурентов она есть; слово у одной-двух страниц — их особенность, а не тема, такие отбрасываем. Второе — сколько раз на тысячу слов она встречается у тех, у кого есть. Берём медиану и четверти, а не среднее, чтобы одна страница с сорока повторами не тянула результат. Третье — во сколько раз эта частота выше, чем в русском языке вообще, по всем формам слова из wordfreq.
Отрыв меньше 20 — общий язык, его отсекаем. Порог — ручка, а не истина: при двадцати уходят «использовать» (18 раз), «пример» (11) и «который» (1,9), но уходит и «сайт» (12) — слово по теме, просто слишком частое в языке. Слово, которое есть у половины конкурентов и больше, — обязательное, остальные — дополнительные. Колонка «вписать» — частота от нижней до верхней четверти конкурентов, пересчитанная на ваш объём; если края совпали, стоит одно число.
Что получилось на запросе «LSI слова»
Я прогнал скрипт по тем же двадцати статьям из ТОПа Яндекса, которые разбирал в статье про LSI-слова на Sostav. Скачивание и подсчёт заняли 11 секунд. Скрипт печатает две таблицы — первые двадцать обязательных слов и первые десять дополнительных. Колонки слева направо: у скольких конкурентов есть слово, сколько раз оно встречается на тысячу слов (медиана), во сколько раз это чаще, чем в языке вообще, и сколько раз вписать его в текст на 1 500 слов.
документов: 20, объём текста: 1500 слов
Обязательные (всего 80): есть у половины конкурентов и больше
слово | у конкурентов | на 1000 слов | чаще, чем в языке | вписать в 1500 слов
-----------------+---------------+--------------+-------------------+--------------------
lsi | 20 из 20 | 8.0 | 7963× | 8–29
семантический | 20 из 20 | 4.8 | 1844× | 5–9
поисковый | 20 из 20 | 8.1 | 602× | 8–21
синоним | 20 из 20 | 2.5 | 352× | 3–7
запрос | 20 из 20 | 12.3 | 339× | 16–25
алгоритм | 20 из 20 | 4.5 | 209× | 5–10
ключевой | 20 из 20 | 5.9 | 159× | 5–16
текст | 20 из 20 | 12.2 | 81× | 12–28
пользователь | 20 из 20 | 4.0 | 58× | 3–10
анализ | 20 из 20 | 2.9 | 29× | 3–6
поиск | 20 из 20 | 3.7 | 28× | 3–8
слово | 20 из 20 | 18.6 | 26× | 22–34
релевантный | 19 из 20 | 2.9 | 2939× | 2–7
индексирование | 19 из 20 | 1.6 | 1590× | 1–3
яндекс | 19 из 20 | 4.0 | 306× | 4–8
google | 19 из 20 | 4.6 | 124× | 4–11
фраза | 19 из 20 | 4.8 | 96× | 4–14
ключ | 19 из 20 | 6.4 | 63× | 6–13
страница | 19 из 20 | 5.9 | 58× | 6–14
семантика | 18 из 20 | 1.6 | 692× | 2–6
Дополнительные (всего 424): есть у меньшинства, но минимум у трёх
слово | у конкурентов | на 1000 слов | чаще, чем в языке | вписать в 1500 слов
-----------------+---------------+--------------+-------------------+--------------------
тематически | 9 из 20 | 1.1 | 1145× | 1–4
естественность | 9 из 20 | 1.0 | 811× | 1–2
вручную | 9 из 20 | 1.0 | 122× | 1–3
вводить | 9 из 20 | 1.1 | 33× | 1–3
оценивать | 9 из 20 | 0.9 | 32× | 1–2
рекомендация | 9 из 20 | 0.8 | 24× | 1
обработка | 9 из 20 | 1.1 | 23× | 1–2
намерение | 9 из 20 | 0.8 | 21× | 1–3
вордстат | 8 из 20 | 1.1 | 1144× | 1–2
нейросеть | 8 из 20 | 1.0 | 986× | 1–3
Всего фильтр прошли 80 обязательных слов и 424 дополнительных. Восемьдесят — много для ТЗ: берите первые 20–30, остальное держите как справочник. Среди дополнительных интереснее всего слова вроде «естественность» — она есть у девяти статей из двадцати, «вордстат» и «нейросеть» — у восьми. Это темы, которые раскрывает меньшинство конкурентов, — кандидаты в разделы, которых у остальных нет.
Чего скрипт не умеет
Это скелет, и у него пять честных ограничений:
- Не снимает выдачу — адреса вы даёте сами.
- Не делит ТОП по типам страниц — отбор на вас.
- Считает только отдельные слова, без фраз вроде «тормозной путь».
- Не знает подсветку Яндекса — слова, которые поисковик сам считает синонимами запроса.
- Не знает новых слов: слова «парсинг» в словаре pymorphy3 нет, и «парсинга» так и осталась «парсинга».
Первые четыре пункта, а заодно подтемы и интент выдачи, закрывает Словоправ — сервис, который я делаю. Он снимает ТОП-20 Яндекса или Google, делит его по типам страниц и собирает ТЗ со словарём темы, долей конкурентов, диапазоном вхождений, устойчивыми фразами и подсветкой за 2–4 минуты. Как выглядит результат, можно посмотреть на примере ТЗ.