Перейти до вмісту

Перелінковка за змістом: як за 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"відсіяно рубрик/тегів: 0 (у них короткий текст, вони схожі на все в рубриці і забивають звіт)")
urls = all_urls[:MAX_URLS]
partial = len(urls) < len(all_urls)
print(f"сторінок у роботі: 0 з 1")
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. Збери вже існуючі внутрішні посилання, але ТІЛЬКИ ті, що всередині абзаців <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