Анализ текстов конкурентов: считаем словарь темы по ТОП-20 на Python

Сервисы анализа текстов конкурентов выдают список «LSI-слов» и редко объясняют, как он посчитан. Посчитаем сами: скачаем тексты ТОП-20, приведём слова к начальной форме и оставим те, что есть у многих конкурентов и редко встречаются в языке вообще. На выходе — скрипт на 95 строк и таблица: слово, у скольких конкурентов оно есть и сколько раз вписать его в ваш текст.

Что понадобится

Python 3.10 или новее и четыре библиотеки. Библиотека trafilatura вытаскивает из страницы основной текст без меню и подвала, razdel режет текст на слова, pymorphy3 приводит слова к начальной форме, а wordfreq знает, как часто слово встречается в русском языке вообще.

⚡ Kodosvet
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.

Скрипт целиком, ниже он разобран по шагам:

⚡ Kodosvet
# 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 слов.

⚡ Kodosvet
документов: 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, остальное держите как справочник. Среди дополнительных интереснее всего слова вроде «естественность» — она есть у девяти статей из двадцати, «вордстат» и «нейросеть» — у восьми. Это темы, которые раскрывает меньшинство конкурентов, — кандидаты в разделы, которых у остальных нет.

Чего скрипт не умеет

Это скелет, и у него пять честных ограничений:

  1. Не снимает выдачу — адреса вы даёте сами.
  2. Не делит ТОП по типам страниц — отбор на вас.
  3. Считает только отдельные слова, без фраз вроде «тормозной путь».
  4. Не знает подсветку Яндекса — слова, которые поисковик сам считает синонимами запроса.
  5. Не знает новых слов: слова «парсинг» в словаре pymorphy3 нет, и «парсинга» так и осталась «парсинга».

Первые четыре пункта, а заодно подтемы и интент выдачи, закрывает Словоправ — сервис, который я делаю. Он снимает ТОП-20 Яндекса или Google, делит его по типам страниц и собирает ТЗ со словарём темы, долей конкурентов, диапазоном вхождений, устойчивыми фразами и подсветкой за 2–4 минуты. Как выглядит результат, можно посмотреть на примере ТЗ.