--- title: Перелинковка по смыслу: как за 10 минут получить план внутренних ссылок бесплатно url: https://blog.krasovskiy.team/ru/perelinkovka-po-smyslu-plan-vnutrennykh-ssylok/ date: 2026-07-16 lang: ru source: blog.krasovskiy.team --- # Перелинковка по смыслу: как за 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/". Иначе переводы одной статьи будут считаться роднёй друг другу, и план перелинковки превратится в мусор. Если сайт на одном языке — поставьте пустую строку "". ```python !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. Лишней оказалась ссылка на автора из блока подписи. Поэтому считаются только ссылки внутри абзацев
. После этого счёт сошёлся с базой: 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 идёт после последнего закрытого — мы внутри заголовка. - **Не внутри кода.** Та же логика для
и : ссылка в примере ломает то, что читатель копирует. Мы на это напоролись на этой самой статье — ссылка залезла внутрь промта.
- **Не на саму себя.** Сравниваем адрес цели с адресом самой статьи. Звучит очевидно, но у нас нашлась одна такая на 372 статьи.
- **Не дважды на одну статью.** Если в тексте уже есть ссылка на этот адрес — вторую не ставим. Все ссылки ведут на разные статьи.
Важная деталь: последние две проверки живут в самой функции вставки, а не в вызывающем коде. Через неё проходят все три ветки, поэтому обойти правило не может ни алгоритм, ни модель. Потолок — 3 ссылки за прогон, после обработки статья помечается служебным комментарием.
### Что в сумме
Эмбеддинги решают, _кто кому родня_. Эмбеддинги предложений решают, _куда именно_ поставить ссылку. Морфология даёт анкор. Три проверки не дают сломать вёрстку. Пороги 0.35 и 0.72 — то, ради чего всё это: они разрешают системе промолчать, когда подходящего места нет.
### Куда это можно улучшить дальше
Часть из того, что мы советовали читателю, мы к этому моменту сделали у себя. Пишем честно, что уже работает, а что ещё нет.
1. **Сделано: модель дописывает место под ссылку.** Раньше было так: подходящего предложения нет — ссылки нет. Теперь последним рубежом включается ветка, которая просит модель вплести упоминание в один абзац и вернуть его переписанным. Правки принимаются только если пройдено семь проверок: анкор 2-4 слова, не из бан-листа, есть в абзаце дословно, не в его начале, без HTML, длина абзаца в пределах ±30%, и главное — смысл переписанного абзаца совпадает с исходным не ниже 0.90 по эмбеддингам. Хоть одна не прошла — абзац не трогаем.
2. **Сделано: порог анкора поднят до двух совпадений**, а сам анкор сокращён до 2-4 слов. На выборке из шести статей это заменило обрубки вроде «Сервер автоматически маршрутизирует запросы: если модель» на нормальные «OpenAI-совместимый API» и «частные mesh-сети». Ссылок стало меньше — и это правильно: система отказывается ставить, когда осмысленного анкора нет.
3. **Сделано: вставка обходит код.** Проверка на и добавлена рядом с остальными.
4. **Осталось: сверять результат с Search Console.** Близость смыслов — это гипотеза. Факт — это одинаковые запросы у двух страниц. Связка «вектор нашёл → GSC подтвердил» превращает догадку в решение. Вот это мы ещё не сделали.
Ни один из этих пунктов не мешает начать: базовая версия из гайда уже даёт результат, а улучшения имеют смысл, когда статей становится много и цена ошибки растёт.
## Если не хотите возиться с кодом
Тот же результат можно получить через Claude Code — вставляете промт, он всё сделает сам. Внутри промта зашиты все грабли выше, чтобы модель не наступила на них заново.
```
Составь план внутренней перелинковки для моего сайта.
1. Собери URL из sitemap: https://МОЙ-САЙТ/sitemap.xml, индекс — обойди вложенные.
Многоязычный? Оставь ОДИН язык (например /ru/).
2. Выкинь страницы рубрик, тегов, автора и корень сайта — это не статьи.
У них короткий текст, они «похожи» на всё в рубрике и забьют отчёт.
3. Со страниц возьми title, h1 и первые 1500 символов текста.
Сначала удали nav, header, footer, aside, script — иначе меню посчитается
как контент и как «ссылка уже стоит».
4. Собери уже существующие внутренние ссылки, но ТОЛЬКО те, что внутри абзацев .
Блок автора, подписи и виджеты «похожие записи» перелинковкой не считаются.
5. Срежь бренд-хвост из title (после |, ➣, —, -), но не обнуляй заголовок,
если он состоит из одного бренда.
6. Посчитай эмбеддинги: sentence-transformers,
paraphrase-multilingual-MiniLM-L12-v2, normalize_embeddings=True.
7. Для каждой статьи найди ближайших по смыслу, пропусти те,
на которые ссылка уже стоит, и предложи максимум 4 новые.
Близость ниже 0.35 не бери — это уже не родня.
8. Покажи статьи, на которые не ведёт ни одна ссылка ИЗ ТЕКСТА других статей.
Если обход неполный — честно скажи, что эта метрика недостоверна.
9. Отдай план: откуда, куда, близость, подсказка анкора. Сохрани в interlink_plan.csv.
```
## Что дальше
Перелинковка и [каннибализация](https://blog.krasovskiy.team/ru/vnutrennie-dublikaty-stranic-chto-eto-i-kak-s-nimi-borotsya/) — две стороны одной медали, и считаются на тех же векторах. Там вы ищете статьи, которые конкурируют друг с другом и режут позиции обеим, тут — наоборот, связываете родню. Сделали одно — сделайте и второе, работа уже проделана.
Финальную проверку делайте в [Search Console](https://blog.krasovskiy.team/ru/google-search-console-obzor-poleznyh-razdelov-i-funkciy/): если по двум страницам идут одни и те же запросы — это не родня, а каннибализация, и ссылками её не лечат.
Если что-то не сходится или скрипт ругается — пишите, разберёмся.