Перейти к содержимому

Перелинковка по смыслу: как за 10 минут получить план внутренних ссылок бесплатно

</> Версия для ИИ

Внутренние ссылки — это то, по чему Google ходит по вашему сайту и решает, какая страница важная, а какая нет. Статья, на которую не ведёт ни одна ссылка из текста других статей, для поиска почти невидима: её никто не «рекомендует» изнутри сайта.

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

В этом гайде — рабочий способ: показать компьютеру смысл каждой статьи и получить готовый план перелинковки. Бесплатно, без API-ключей, за 10 минут. Всё проверено на живом блоге из 125 статей, включая грабли, на которые я наступил лично.

Почему «Похожие записи» — это не перелинковка

Стандартные плагины цепляют статьи по рубрике или тегу и лепят блок ссылок в подвал. Три беды:

  • Рубрика — это не смысл. В одной рубрике «SEO» может лежать статья про домены и статья про тексты: связи между ними ноль.
  • Подвал не читают — ни люди, ни поисковик не придают ему того же веса, что ссылке внутри текста.
  • Анкор «Читайте также» не говорит поисковику ничего о том, что на той стороне.

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

Что такое смысловая близость, если по-простому

Компьютер не понимает слов, зато умеет превращать текст в набор чисел — вектор. Тексты про одно и то же дают близкие векторы, даже если слова разные: «купить авто» и «покупка машины» окажутся рядом. Это называется эмбеддинги.

Дальше всё просто: превращаем каждую статью в вектор, сравниваем все пары между собой и получаем список — кто кому родня. Это и есть кандидаты на перелинковку.

Модель для этого нужна бесплатная и многоязычная, она скачивается сама и работает прямо в браузере через Google Colab. Ни ключей, ни оплаты, ни своего сервера.

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

  • Google Colab — бесплатно, нужен только аккаунт Google.
  • Адрес вашего sitemap — обычно site.ru/sitemap.xml или site.ru/sitemap_index.xml. Если не знаете — загляните в site.ru/robots.txt, он там указан.
  • 10 минут. Ничего устанавливать на компьютер не нужно.

Шаг 1. Открыть Colab и вставить скрипт

Заходите на colab.research.google.com, создаёте новый блокнот (File → New notebook), вставляете код ниже в ячейку.

В блоке настроек меняете две строки под себя:

  • SITEMAP_URL — адрес вашего sitemap.
  • URL_FILTER — если сайт многоязычный, оставьте один язык, например «/ru/». Иначе переводы одной статьи будут считаться роднёй друг другу, и план перелинковки превратится в мусор. Если сайт на одном языке — поставьте пустую строку «».
!pip -q install requests beautifulsoup4 lxml pandas scikit-learn sentence-transformers

# ============================================
# SEMANTIC INTERLINK PLANNER (Google Colab)
# Находит по смыслу, куда каждой статье поставить внутренние ссылки,
# и показывает сирот — страницы, на которые никто не ссылается.
# ============================================

import re, time, collections, requests
import numpy as np, pandas as pd
from bs4 import BeautifulSoup
from sklearn.metrics.pairwise import cosine_similarity
from xml.etree import ElementTree as ET
from sentence_transformers import SentenceTransformer

SITEMAP_URL = "https://МОЙ-САЙТ/sitemap.xml"
URL_FILTER  = "/ru/"
MAX_URLS    = 500
EXCLUDE     = ["/category/", "/tag/", "/author/", "/page/"]   # рубрики и теги — не статьи
LINKS_PER_PAGE = 4        # потолок ссылок на статью
MIN_SIM     = 0.35        # ниже — родня притянута за уши
BODY_CHARS  = 1500
HEADERS = {"User-Agent": "Mozilla/5.0 (interlink planner)"}

def clean(t):
    return re.sub(r"\s+", " ", (t or "").replace("\xa0", " ")).strip()

def fetch(u):
    r = requests.get(u, headers=HEADERS, timeout=20); r.raise_for_status(); return r.text

def sitemap(u, acc=None):
    if acc is None: acc = []
    root = ET.fromstring(fetch(u)); tag = lambda x: x.tag.split("}")[-1]
    if tag(root) == "sitemapindex":
        for sm in root:
            for ch in sm:
                if tag(ch) == "loc": sitemap(clean(ch.text), acc)
    elif tag(root) == "urlset":
        for ue in root:
            for ch in ue:
                if tag(ch) == "loc": acc.append(clean(ch.text))
    return acc

def page(u, host):
    try:
        html = fetch(u)
    except Exception:
        return None
    s = BeautifulSoup(html, "lxml")
    # ВАЖНО: сначала выкидываем меню/подвал/сайдбар, иначе их ссылки
    # посчитаются как «уже стоит» (у нас так вышло 32 ссылки на статью вместо 2.5)
    for t in s(["script","style","noscript","svg","nav","footer","header","aside","form"]): t.decompose()
    body = s.find("article") or s.find("main") or s
    out = set()
    for a in body.find_all("a", href=True):
        h = a["href"]
        if host not in h: continue
        # только ссылки ВНУТРИ АБЗАЦА: блок автора, подписи и виджеты — не перелинковка
        if not a.find_parent("p"): continue
        out.add(h.split("#")[0].rstrip("/"))
    title = clean(s.title.get_text(" ")) if s.title else u
    h1 = clean(s.h1.get_text(" ")) if s.h1 else ""
    return {"url": u.rstrip("/"), "title": title, "h1": h1,
            "text": clean(s.get_text(" ", strip=True))[:BODY_CHARS], "outgoing": out}

host = re.sub(r"https?://([^/]+)/.*", r"\1", SITEMAP_URL)
all_urls = [u for u in dict.fromkeys(sitemap(SITEMAP_URL)) if URL_FILTER in u]
before = len(all_urls)
all_urls = [u for u in all_urls if not any(x in u for x in EXCLUDE)]
# главная и корни языков — не статьи, целями быть не должны
all_urls = [u for u in all_urls if len(u.rstrip("/").split(URL_FILTER.strip("/"))[-1].strip("/")) > 3]
print(f"отсеяно рубрик/тегов: {before - len(all_urls)} (у них короткий текст, они «похожи» на всё в рубрике и забивают отчёт)")
urls = all_urls[:MAX_URLS]
partial = len(urls) < len(all_urls)
print(f"страниц в работе: {len(urls)} из {len(all_urls)}")
if partial:
    print("ВНИМАНИЕ: обход неполный — сироты считаться не будут (входящие видно только по всему сайту).")
    print("Чтобы найти сирот, подними MAX_URLS до размера сайта.")
docs = [d for d in (page(u, host) for u in urls) if d]
print("разобрано:", len(docs))

# бренд-хвост срезаем, как в аудите каннибализации
tails = collections.Counter()
for d in docs:
    for sep in ["|","➣","—","–","-","·","»"]:
        if sep in d["title"]: tails[d["title"].rsplit(sep,1)[-1].strip().lower()] += 1
brand = tails.most_common(1)[0][0] if tails and tails.most_common(1)[0][1] >= max(3, len(docs)*0.3) else None
for d in docs:
    t = d["title"]
    cut = t[:t.lower().rfind(brand)].rstrip(" |➣—–-·»") if brand and brand in t.lower() else t
    d["title_clean"] = cut or d["h1"] or t          # срез не должен обнулять заголовок

m = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
emb = m.encode([f'{d["title_clean"]}. {d["h1"]}. {d["text"]}' for d in docs],
               normalize_embeddings=True, show_progress_bar=False)
sim = emb @ emb.T
np.fill_diagonal(sim, -1)

rows = []
for i, d in enumerate(docs):
    order = np.argsort(-sim[i])
    added = 0
    for j in order:
        if added >= LINKS_PER_PAGE: break
        s = float(sim[i][j])
        if s < MIN_SIM: break
        tgt = docs[j]
        if tgt["url"] in d["outgoing"]:      # ссылка уже стоит
            continue
        rows.append({"откуда": d["title_clean"][:44], "куда": tgt["title_clean"][:44],
                     "близость": round(s,3), "анкор_подсказка": tgt["h1"][:48] or tgt["title_clean"][:48],
                     "url_откуда": d["url"], "url_куда": tgt["url"]})
        added += 1

plan = pd.DataFrame(rows)
print("\nпредложено новых ссылок:", len(plan))
print("уже стоит ссылок в текстах:", sum(len(d["outgoing"]) for d in docs))

# сироты: на кого никто не ссылается и кому никто не предлагает
incoming = collections.Counter()
for d in docs:
    for o in d["outgoing"]: incoming[o] += 1
if partial:
    print("статьи без входящих: пропущено (обход неполный)")
    orphans = []
else:
    orphans = [d["title_clean"][:50] for d in docs if incoming[d["url"]] == 0]
    print("статей без входящих ссылок ИЗ ТЕКСТОВ других статей:", len(orphans))
    for o in orphans[:5]: print("   -", o)

print("\nтоп-8 предложений:")
if len(plan):
    for _, r in plan.sort_values("близость", ascending=False).head(8).iterrows():
        print(f'  {r["близость"]}  {r["откуда"][:34]:34} -> {r["куда"][:34]}')

Жмёте кнопку запуска. Первый прогон занимает пару минут: скачивается модель. Дальше скрипт обходит sitemap, качает страницы и считает.

Шаг 2. Прочитать, что он выдал

На выходе четыре вещи:

  1. Сколько ссылок уже стоит в текстах. Считаются только ссылки внутри абзацев — меню, подвал и блок автора не в счёт.
  2. План новых ссылок — таблица: откуда, куда, близость, подсказка для анкора. Отсортирована по близости.
  3. Статьи без входящих ссылок — те самые невидимки. Метрика честная только при полном обходе сайта.
  4. Файл interlink_plan.csv — весь план, чтобы работать спокойно и вычёркивать сделанное.

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

Шаг 3. Поставить ссылки правильно

План — это подсказка, а не приказ. Ставите руками, по трём правилам:

  • Только в текст. Ссылка живёт в абзаце, где вы естественно упоминаете тему. Не в подвал, не в блок «читайте также».
  • Анкор — по смыслу целевой статьи. Не «тут», не «читайте также», а фраза, описывающая то, куда ведёте.
  • Не в заголовки. Ссылка внутри h2 ломает и вёрстку, и разметку. Это отдельные грабли, на которых мы уже сидели.

Потолок — 4 ссылки на статью. Двадцать ссылок не в двадцать раз лучше: вес размывается, читатель уходит. У нас на блоге в среднем 2.3 ссылки на статью, и этого достаточно.

Грабли, на которые я наступил, пока писал этот скрипт

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

1. Меню считалось перелинковкой

Первая версия насчитала 32 внутренние ссылки на статью при реальных 2.5. Причина: ссылки собирались со всей страницы, включая меню и подвал. Из-за этого скрипт думал, что ссылка уже стоит, и не предлагал нужное. Лечится удалением nav, header, footer, aside до сбора ссылок.

2. Ссылка на автора

После первой правки осталось 4.15 ссылки на статью вместо 2.3. Лишней оказалась ссылка на автора из блока подписи. Поэтому считаются только ссылки внутри абзацев <p>. После этого счёт сошёлся с базой: 2.28 против 2.3.

3. Рубрики забивали весь топ

При полном обходе первые места заняли страницы «CRM», «Дизайн», «Платежи» с близостью 0.93. Это не статьи, а рубрики: у них короткий текст, и они похожи на всё в своей рубрике сразу. Отсеиваются по /category/, /tag/, /author/.

4. Пустой заголовок

Скрипт срезает бренд из title (хвост вида «— Название сайта»), потому что этот хвост есть на каждой странице и задирает схожесть всем парам. Но если title состоит из одного бренда — например, у главной страницы — срез обнулял заголовок. Теперь есть запасной вариант.

Наши цифры

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

  • 927 внутренних ссылок на 372 статьи.
  • 98% статей имеют ссылки в тексте — 366 из 372.
  • 2.3 ссылки на статью в среднем, потолок — 4.
  • 47 статей при этом всё ещё без входящих ссылок из текстов. Даже при автоматике остаётся хвост, который надо добирать руками.

Последняя цифра — самая честная. Автоматика не делает работу за вас на сто процентов, она снимает 90% рутины и показывает, где осталось.

Как мы потом довели этот метод до автомата

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

  • Эмбеддинги считает mistral-embed, а не локальная MiniLM. Векторы лежат в Pinecone — посчитал один раз, дальше добавляются только новые статьи.
  • Ищем не «статью-родню», а конкретное предложение. Считаем эмбеддинг каждого предложения статьи и заголовков кандидатов — и находим предложение, которое действительно о том же. Порог: косинус 0.72.
  • Анкор берётся из этого предложения — из живого текста, с правильной морфологией. Никаких «читайте также».
  • Жёсткие пороги. Кандидат ниже 0.35 не берём. Подходящего предложения нет — ссылку не ставим вообще. Ноль ссылок лучше, чем ссылка не к месту.
  • Потолок 3 ссылки за один прогон. В сумме по статье набегает до 4, в среднем 2.3 — цифры выше.

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

Наш метод целиком: как повторить у себя

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

Слой 1. Индексируем статьи в векторную базу

Берём заголовок плюс первые 6500 символов текста без HTML. Считаем эмбеддинг и кладём в Pinecone — в неймспейс своего языка. Это важно: если свалить все языки в кучу, переводы одной статьи станут ближайшей роднёй друг другу, и вся перелинковка уедет в переводы.

# 1) эмбеддинг статьи (1024 измерения)
curl https://api.mistral.ai/v1/embeddings \
  -H "Authorization: Bearer $MISTRAL_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"mistral-embed","input":["Заголовок. Первые 6500 символов текста..."]}'

# 2) кладём вектор в Pinecone, неймспейс = язык статьи
curl https://ВАШ-ИНДЕКС.svc.ВАШ-РЕГИОН.pinecone.io/vectors/upsert \
  -H "Api-Key: $PINECONE_KEY" \
  -H "X-Pinecone-API-Version: 2025-01" \
  -H "Content-Type: application/json" \
  -d '{
        "namespace": "ru",
        "vectors": [{
          "id": "1849",
          "values": [0.013, -0.042, ...],
          "metadata": {"post_id":1849, "lang":"ru", "title":"...", "url":"https://...", "cat":"SEO"}
        }]
      }' 

В метаданные кладём post_id, язык, заголовок, URL и рубрику — потом они нужны, чтобы построить ссылку и анкор, не ходя лишний раз в базу. Ограничение модели — 7000 символов на вход, поэтому текст режем заранее.

Слой 2. Ищем кандидатов

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

curl https://ВАШ-ИНДЕКС.svc.ВАШ-РЕГИОН.pinecone.io/query \
  -H "Api-Key: $PINECONE_KEY" \
  -H "X-Pinecone-API-Version: 2025-01" \
  -d '{"namespace":"ru", "vector":[...], "topK":15, "includeMetadata":true}'

# берём topK=14 кандидатов, порог score >= 0.35 — ниже уже не родня

Порядок в пайплайне такой: сгенерировали статью → перелинковали → перевели → и только потом проиндексировали. Новая статья ищет родню среди уже проиндексированных, сама себе кандидатом быть не может.

Слой 3. Находим предложение, а не абы куда

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

  • Режем статью на предложения: выкидываем заголовки, списки и служебные комментарии, берём только абзацы, делим по точкам, оставляем предложения длиннее 30 символов. Берём первые 60.
  • Одним батч-запросом считаем эмбеддинги всех предложений и всех заголовков кандидатов.
  • Для каждого кандидата ищем предложение с максимальным косинусом. Порог 0.72 — если ни одно предложение не дотянуло, ссылку не ставим вообще.

Последнее правило дороже всего остального: лучше ноль ссылок, чем ссылка не к месту. Именно поэтому у нас 2.3 ссылки на статью, а не 4 из 4.

Слой 4. Анкор из живого текста

Анкор не придумываем, а вырезаем из найденного предложения. Скользим окном в 6, 5, 4 и 3 слова и считаем, сколько слов окна совпадает со словами заголовка цели — сравниваем по первым пяти буквам, чтобы падежи и окончания не мешали. Берём лучшее окно; требуем минимум одно совпадение и длину анкора от 8 символов.

Честно про слабое место: порога «одно совпадение» иногда мало. На этой самой статье фраза «Внутренние ссылки — это то, по» совпала с заголовком «Внутренние дубликаты страниц» одним корнем — и в анкор уехал обрубок фразы. На обычных статьях совпадений больше и анкор выходит нормальный, но если будете повторять — поднимайте порог до двух совпадений.

Слой 5. Вставляем безопасно

Ищем первое вхождение анкора в HTML и перед вставкой прогоняем шесть проверок. Провалилась любая — ищем следующее вхождение, не нашли ни одного — кандидат пропускается.

  • Не внутри тега. Сравниваем позиции последних «<» и «>» перед местом вставки: если открывающая скобка позже — мы внутри атрибута.
  • Не внутри существующей ссылки. Считаем открытые и закрытые «a» до этого места.
  • Не в заголовке. Если последний открытый h1-h6 идёт после последнего закрытого — мы внутри заголовка.
  • Не внутри кода. Та же логика для <pre> и <code>: ссылка в примере ломает то, что читатель копирует. Мы на это напоролись на этой самой статье — ссылка залезла внутрь промта.
  • Не на саму себя. Сравниваем адрес цели с адресом самой статьи. Звучит очевидно, но у нас нашлась одна такая на 372 статьи.
  • Не дважды на одну статью. Если в тексте уже есть ссылка на этот адрес — вторую не ставим. Все ссылки ведут на разные статьи.

Важная деталь: последние две проверки живут в самой функции вставки, а не в вызывающем коде. Через неё проходят все три ветки, поэтому обойти правило не может ни алгоритм, ни модель. Потолок — 3 ссылки за прогон, после обработки статья помечается служебным комментарием.

Что в сумме

Эмбеддинги решают, кто кому родня. Эмбеддинги предложений решают, куда именно поставить ссылку. Морфология даёт анкор. Три проверки не дают сломать вёрстку. Пороги 0.35 и 0.72 — то, ради чего всё это: они разрешают системе промолчать, когда подходящего места нет.

Куда это можно улучшить дальше

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

  1. Сделано: модель дописывает место под ссылку. Раньше было так: подходящего предложения нет — ссылки нет. Теперь последним рубежом включается ветка, которая просит модель вплести упоминание в один абзац и вернуть его переписанным. Правки принимаются только если пройдено семь проверок: анкор 2-4 слова, не из бан-листа, есть в абзаце дословно, не в его начале, без HTML, длина абзаца в пределах ±30%, и главное — смысл переписанного абзаца совпадает с исходным не ниже 0.90 по эмбеддингам. Хоть одна не прошла — абзац не трогаем.
  2. Сделано: порог анкора поднят до двух совпадений, а сам анкор сокращён до 2-4 слов. На выборке из шести статей это заменило обрубки вроде «Сервер автоматически маршрутизирует запросы: если модель» на нормальные «OpenAI-совместимый API» и «частные mesh-сети». Ссылок стало меньше — и это правильно: система отказывается ставить, когда осмысленного анкора нет.
  3. Сделано: вставка обходит код. Проверка на <pre> и <code> добавлена рядом с остальными.
  4. Осталось: сверять результат с Search Console. Близость смыслов — это гипотеза. Факт — это одинаковые запросы у двух страниц. Связка «вектор нашёл → GSC подтвердил» превращает догадку в решение. Вот это мы ещё не сделали.

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

Если не хотите возиться с кодом

Тот же результат можно получить через Claude Code — вставляете промт, он всё сделает сам. Внутри промта зашиты все грабли выше, чтобы модель не наступила на них заново.

Составь план внутренней перелинковки для моего сайта.

1. Собери URL из sitemap: https://МОЙ-САЙТ/sitemap.xml, индекс — обойди вложенные.
   Многоязычный? Оставь ОДИН язык (например /ru/).
2. Выкинь страницы рубрик, тегов, автора и корень сайта — это не статьи.
   У них короткий текст, они «похожи» на всё в рубрике и забьют отчёт.
3. Со страниц возьми title, h1 и первые 1500 символов текста.
   Сначала удали nav, header, footer, aside, script — иначе меню посчитается
   как контент и как «ссылка уже стоит».
4. Собери уже существующие внутренние ссылки, но ТОЛЬКО те, что внутри абзацев <p>.
   Блок автора, подписи и виджеты «похожие записи» перелинковкой не считаются.
5. <a href="https://blog.krasovskiy.team/ru/seo-y-kontent-marketynh-praktycheskoe-rukovodstvo-dlia-brendov/">Срежь бренд-хвост из title (после |</a>, ➣, —, -), но не обнуляй заголовок,
   если он состоит из одного бренда.
6. Посчитай эмбеддинги: sentence-transformers,
   paraphrase-multilingual-MiniLM-L12-v2, normalize_embeddings=True.
7. Для каждой статьи найди ближайших по смыслу, пропусти те,
   на которые ссылка уже стоит, и предложи максимум 4 новые.
   Близость ниже 0.35 не бери — это уже не родня.
8. Покажи статьи, на которые не ведёт ни одна ссылка ИЗ ТЕКСТА других статей.
   Если обход неполный — честно скажи, что эта метрика недостоверна.
9. Отдай план: откуда, куда, близость, подсказка анкора. Сохрани в interlink_plan.csv.

Что дальше

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

Финальную проверку делайте в Search Console: если по двум страницам идут одни и те же запросы — это не родня, а каннибализация, и ссылками её не лечат.

Если что-то не сходится или скрипт ругается — пишите, разберёмся.

Krasovskiy Blog