--- title: Перелінковка за змістом: як за 10 хвилин отримати план внутрішніх посилань безкоштовно url: https://blog.krasovskiy.team/perelinkovka-za-zmistom-iak-za-10-khvylyn-otrymaty-plan-vnutrishnikh-posylan-bezkoshtovno/ date: 2026-07-16 lang: uk 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"відсіяно рубрик/тегів: 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. Збери вже існуючі внутрішні посилання, але ТІЛЬКИ ті, що всередині абзаців

. Блок автора, підписи та віджети «подібні записи» перелінковкою не вважаються. 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/vnutrishni-dublikaty-storinok-shho-cze-i-yak-iz-nymy-borotysya/) — дві сторони однієї медалі, і вважаються на тих же векторах. Там ви шукаєте статті, які конкурують одна з одною і ріжуть позиції обох, тут навпаки, пов'язуєте рідню. Зробили одне — зробіть і друге, робота вже виконана. Фінальну перевірку робіть у [Search Console](https://blog.krasovskiy.team/google-search-console-oglyad-korysnyh-rozdiliv-i-funkczij/): якщо за двома сторінками йдуть одні й ті самі запити — це не рідня, а канібалізація, і посиланнями її не лікують. Якщо щось не сходиться чи скрипт лається – пишіть, розберемося.