Skip to content
Coding Script or NotebookGoogle ColabFree

Internal Link Audit and Topic Clustering Notebook with Embeddings

ForInternal linkingContent audits

By Lazarina Stoy· Updated 24 Oct 2026

Good internal linking starts with two questions: which pages are under-linked, and which pages belong together? This free Colab notebook answers both. It audits your current links and anchor text, then uses embeddings to group pages into topic clusters and shortlist the links that are missing inside each one.

Who it’s for

SEOs running internal linking projects on content sites, and anyone planning hub pages who wants the clusters to come from the content itself instead of a guess.

How to use it

  1. Load the data. Use a Screaming Frog crawl (a custom extraction of your main content plus the All Inlinks export), or run the demo dataset first: about 70 MLforSEO posts pulled through the WordPress REST API.
  2. Link audit metrics. Inlinks, unique inlinks, in-content inlinks, orphan pages, and broken, nofollow or UTM-tagged internal links.
  3. Anchor text analysis. Generic, branded, mixed, exact-title and descriptive anchors, anchor length and the most common bigrams and trigrams.
  4. Embeddings. One vector per page with BAAI/bge-small-en-v1.5 (or embeddings exported from Screaming Frog), and a list of possible duplicates (cosine similarity of 0.95 or more).
  5. Topic clustering. K-means with the number of clusters picked by silhouette score, or agglomerative clustering or HDBSCAN. Clusters are named with c-TF-IDF, pillar candidates are flagged and a 2D map shows the result.
  6. Bridge pages. Pages close to two clusters, your candidates for links between topics.
  7. Link opportunities. Similar pages that aren’t linked yet, prioritised so pillars and pages with no in-content inlinks come first, each with an anchor phrase found in the source page’s own text.
  8. Export CSVs and the cluster map for your audit deliverable.

What it costs

Nothing. The free Colab CPU runtime is enough, there are no API keys, and the embedding model (about 130 MB) downloads on the first run.

Limits

  • The demo only sees links in the post body, so its orphans are “pages with no contextual links”. A full crawl gives you menus, footers and link position.
  • Similarity thresholds are relative: the notebook uses a percentile of your own site’s similarity scores because different models score differently.
  • The suggestions are a shortlist. Read them before you add anything.

Want it on a schedule? The same folder has automate_internal_linking.py for orphan checks, passage-level link suggestions with anchor guardrails, link health and applying approved links to WordPress. For a no-code start, try the Internal Link Opportunities Quick Find sheet. Read the full approach in 10 Ways to Automate Internal Linking with AI and learn the methods in the Introduction to Machine Learning for SEO course.

What’s included

  • Link metrics per page: inlinks, in-content inlinks, orphans, broken, nofollow and UTM-tagged internal links
  • Anchor text classified as generic, branded, exact-title or descriptive, with top bigrams and trigrams
  • Page embeddings, possible duplicates and automatic topic clusters with names, pillar candidates and a 2D map
  • Bridge pages between clusters and prioritised link opportunities with an anchor phrase from the source page
  • Works on a Screaming Frog crawl or a WordPress demo dataset, no API keys
. Disclosure menus (APG pattern), hover intent, mobile dialog with focus trap, announcement dismiss, skip-link target, newsletter status message. No dependencies. */ (function () { 'use strict'; var d = document;/* ---------- skip link target: #main, else the page's
, else the first block after the header */ (function () { var mains = d.querySelectorAll('main'); if (mains.length > 1) { // Our post-content
plus another one: drop the landmark from ours. var ours = d.querySelector('main[data-mlfc-main]'); if (ours) { ours.setAttribute('role', 'none'); ours.removeAttribute('id'); } } var skip = d.querySelector('.mlfc-skip'); if (!skip) return; var t = d.getElementById('main'); if (!t || (t.getAttribute('role') === 'none')) { t = d.querySelector('main:not([role="none"]), [role="main"]'); if (!t) { var hd = skip.closest('.wp-block-template-part') || skip.closest('.mlfc-hdw'); t = hd && hd.nextElementSibling; } if (!t) return; if (!t.id) t.id = 'main'; skip.setAttribute('href', '#' + t.id); } if (!t.hasAttribute('tabindex')) t.setAttribute('tabindex', '-1'); skip.addEventListener('click', function () { setTimeout(function () { t.focus({ preventScroll: false }); }, 0); }); })();/* ---------- announcement dismiss */ d.querySelectorAll('[data-mlfc-ann]').forEach(function (bar) { var btn = bar.querySelector('[data-mlfc-ann-close]'); if (!btn) return; btn.addEventListener('click', function () { try { localStorage.setItem(bar.getAttribute('data-mlfc-ann'), '1'); } catch (e) {} var next = d.querySelector('.mlfc-logo'); bar.hidden = true; if (next) next.focus(); }); });/* ---------- disclosure dropdowns */ var items = [].slice.call(d.querySelectorAll('.mlfc-hdw [data-mlfc-dd]')); var fine = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches; function btnOf(it) { return it.querySelector('button[aria-controls]'); } function panelOf(it) { return d.getElementById(btnOf(it).getAttribute('aria-controls')); } function isOpen(it) { return btnOf(it).getAttribute('aria-expanded') === 'true'; } function setOpen(it, open) { var b = btnOf(it), p = panelOf(it); if (!b || !p) return; b.setAttribute('aria-expanded', open ? 'true' : 'false'); p.hidden = !open; if (open) { // keep the panel inside the viewport p.style.left = ''; p.style.right = ''; var r = p.getBoundingClientRect(); if (r.right > window.innerWidth - 12) { p.style.left = 'auto'; p.style.right = '0'; } } } function closeAll(except) { items.forEach(function (it) { if (it !== except && isOpen(it)) setOpen(it, false); }); }items.forEach(function (it) { var b = btnOf(it), tIn, tOut; if (!b) return; b.addEventListener('click', function () { clearTimeout(tIn); clearTimeout(tOut); var open = !isOpen(it); closeAll(it); setOpen(it, open); }); if (fine) { // Hover intent: open after 300 ms, close 400 ms after the pointer leaves trigger + panel. it.addEventListener('mouseenter', function () { clearTimeout(tOut); tIn = setTimeout(function () { closeAll(it); setOpen(it, true); }, 300); }); it.addEventListener('mouseleave', function () { clearTimeout(tIn); tOut = setTimeout(function () { setOpen(it, false); }, 400); }); } it.addEventListener('focusout', function (e) { if (e.relatedTarget && !it.contains(e.relatedTarget)) setOpen(it, false); }); it.addEventListener('keydown', function (e) { if (e.key === 'Escape' && isOpen(it)) { e.stopPropagation(); setOpen(it, false); b.focus(); } }); }); d.addEventListener('click', function (e) { items.forEach(function (it) { if (isOpen(it) && !it.contains(e.target)) setOpen(it, false); }); }); d.addEventListener('keydown', function (e) { if (e.key !== 'Escape') return; items.forEach(function (it) { if (isOpen(it)) { setOpen(it, false); btnOf(it).focus(); } }); });/* ---------- mobile menu dialog */ var menuBtn = d.querySelector('.mlfc-menu-btn'); var dlg = d.getElementById('mlfc-mnav'); if (menuBtn && dlg) { var FOC = 'a[href],button:not([disabled]),input,select,textarea,[tabindex]:not([tabindex="-1"])'; var openMenu = function () { dlg.hidden = false; menuBtn.setAttribute('aria-expanded', 'true'); d.body.classList.add('mlfc-lock'); var c = dlg.querySelector('[data-mlfc-close]'); if (c) c.focus(); }; var closeMenu = function (restore) { if (dlg.hidden) return; dlg.hidden = true; menuBtn.setAttribute('aria-expanded', 'false'); d.body.classList.remove('mlfc-lock'); if (restore !== false) menuBtn.focus(); }; menuBtn.addEventListener('click', function () { dlg.hidden ? openMenu() : closeMenu(); }); dlg.querySelector('[data-mlfc-close]').addEventListener('click', function () { closeMenu(); }); dlg.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); closeMenu(); return; } if (e.key !== 'Tab') return; var f = [].filter.call(dlg.querySelectorAll(FOC), function (el) { return el.offsetParent !== null || el === d.activeElement; }); if (!f.length) return; var first = f[0], last = f[f.length - 1]; if (e.shiftKey && d.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && d.activeElement === last) { e.preventDefault(); first.focus(); } }); // In-page links (e.g. #courses) close the menu first. dlg.addEventListener('click', function (e) { var a = e.target.closest && e.target.closest('a[href^="#"]'); if (a) closeMenu(false); }); var mq = window.matchMedia('(min-width: 1024px)'); var onMq = function () { if (mq.matches) closeMenu(false); }; if (mq.addEventListener) mq.addEventListener('change', onMq); else if (mq.addListener) mq.addListener(onMq); }/* ---------- newsletter: plain POST to Kit in a new tab (same pattern as mlfa2_newsletter()); say so in the live region */ d.querySelectorAll('form[data-mlfc-nl]').forEach(function (form) { var msg = form.querySelector('.mlfc-nl__msg'); form.addEventListener('submit', function () { if (msg) msg.textContent = 'Thanks. Kit opens in a new tab: confirm your subscription there and check your inbox.'; }); }); })();