DEV Community

Cover image for Automating Internal Linking in n8n: How to Parse WordPress Sitemaps & Inject Contextual Links with Gemini Flash
Ruesch Manny
Ruesch Manny

Posted on

Automating Internal Linking in n8n: How to Parse WordPress Sitemaps & Inject Contextual Links with Gemini Flash

Internal linking is one of the highest-leverage SEO strategies for domain authority and crawl efficiency. Yet, in most content pipelines, it is the first thing to fall apart.

Writers either guess URLs from memory, leave it to bloated WordPress plugins that slow down page loads with database joins, or skip it entirely. When scaling programmatic or multilingual content, manually cross-referencing hundreds of URLs across multiple languages becomes an operational nightmare.

In this guide, we'll build a production-ready, zero-dependency n8n pipeline that:

  1. Recursively traverses WordPress / Rank Math XML sitemaps directly in memory.
  2. Buckets discovered URLs by language and extracts semantic slug titles.
  3. Uses Gemini 2.5 Flash to analyze relevance and generate contextual anchor text.
  4. Enforces a strict Anti-Hallucination Domain Guard to guarantee the LLM never invents dead links.

The Architecture at a Glance

┌───────────────────────────────┐
│     Live XML Sitemap          │ (sitemap_index.xml or post-sitemap.xml)
└───────────────────────────────┘
                │
                ▼
┌───────────────────────────────┐
│   Node: Parse sitemap URLs    │───► Regex extraction (no heavy XML parser)
└───────────────────────────────┘     Buckets by language (/de/, /fr/, root)
                │
                ▼
┌───────────────────────────────┐
│   Node: Gemini Flash Matcher  │───► Analyzes candidate URLs against topic
└───────────────────────────────┘     Selects top 4-6 contextual links
                │
                ▼
┌───────────────────────────────┐
│ Node: Anti-Hallucination Gate │───► Host allowlist + Set intersection
└───────────────────────────────┘     Filters out any invented/spoofed URLs
                │
                ▼
┌───────────────────────────────┐
│   Markdown & HTML Assembly    │───► Injects "Related Articles" block &
└───────────────────────────────┘     WordPress Custom HTML format
Enter fullscreen mode Exit fullscreen mode

Step 1: Parsing the WordPress XML Sitemap in n8n

Most enterprise WordPress sites (using Yoast, Rank Math, or SEOPress) do not serve a single flat XML file. They serve an index sitemap (sitemap_index.xml) that points to individual sub-sitemaps like post-sitemap.xml, post-sitemap2.xml, and page-sitemap.xml.

Instead of bloating our n8n instance with external npm packages (xml2js, fast-xml-parser), we can parse the XML structure deterministically using n8n's native helpers.httpRequest and regex matching inside a standard Code node:

// Parse sitemap_index + post-* sub-sitemaps via helpers.httpRequest
const CFG = $('CONFIG').first().json;
const urlsByLang = { de: [], en: [], fr: [] };
const LANGS = ['de', 'en', 'fr'];
const ROOT_LANG = CFG.root_language || 'en';

function firstSeg(loc) {
  const path = loc.replace(/^https?:\/\/[^/]+/, '');
  return (path.split('/').filter(Boolean)[0] || '').toLowerCase();
}

function titleFromUrl(u) {
  const path = u.replace(/^https?:\/\/[^/]+/, '').replace(new RegExp('^/(' + LANGS.join('|') + ')/'), '/');
  const segs = path.split('/').filter(Boolean);
  const slug = segs[segs.length - 1] || '';
  return slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}

// 1. Fetch the master sitemap index
const idx = await this.helpers.httpRequest({ url: CFG.sitemap_url, json: false });
const idxStr = String(idx);
const allLocs = [...idxStr.matchAll(/<loc>(.*?)<\/loc>/g)].map(m => m[1]);

let children = [];
if (/<sitemapindex/i.test(idxStr)) {
  // Filter for post sitemaps, capped to top 20 to prevent timeout
  const posts = allLocs.filter(u => /post-sitemap\d*\.xml/i.test(u));
  children = (posts.length ? posts : allLocs.filter(u => /\.xml/i.test(u))).slice(0, 20);
} else {
  children = [CFG.sitemap_url];
}

// 2. Extract active URLs and categorize by language
for (const childUrl of children) {
  const host = childUrl.replace(/^https?:\/\//, '').split('/')[0].toLowerCase();
  if (host !== CFG.brand_domain && host !== ('www.' + CFG.brand_domain)) continue;

  try {
    const xml = await this.helpers.httpRequest({ url: childUrl, json: false });
    for (const match of [...String(xml).matchAll(/<loc>(.*?)<\/loc>/g)]) {
      const loc = match[1].trim();
      const seg = firstSeg(loc);
      const lang = LANGS.indexOf(seg) >= 0 ? seg : ROOT_LANG;
      if (lang) urlsByLang[lang].push(loc);
    }
  } catch (err) {
    // Graceful continuation if a sub-sitemap is 404
  }
}

// 3. Format candidates with auto-generated titles
for (const lang of LANGS) {
  urlsByLang[lang] = urlsByLang[lang].slice(0, 40).map(u => ({
    url: u,
    title: titleFromUrl(u)
  }));
}

return [{ json: { ...$input.first().json, sitemap_urls_by_lang: urlsByLang } }];
Enter fullscreen mode Exit fullscreen mode

Step 2: Semantic Link Selection with Gemini Flash

Once candidate URLs are grouped by language, we invoke Google Gemini 2.5 Flash. Because Flash is extremely fast and costs only $0.075 per million input tokens, analyzing 40 candidate URLs costs approximately $0.0002.

The System Prompt:

You choose relevant internal links for 3 language versions of an article.
For EACH language, you receive candidate URLs (title + URL) and the topic of the current article.

RULES:
1. Use ONLY URLs present in the provided list. NEVER invent or extrapolate a URL.
2. anchor_text: a natural, conversational label in the language (do not just dump the raw slug).
3. Do NOT include the current article itself.
4. Output strictly valid JSON.

JSON Schema:
{
  "de": [{"anchor_text": "...", "target_url": "..."}],
  "en": [{"anchor_text": "...", "target_url": "..."}],
  "fr": [{"anchor_text": "...", "target_url": "..."}]
}
Enter fullscreen mode Exit fullscreen mode

Step 3: The Anti-Hallucination Domain Guard

LLMs can occasionally fabricate plausible-looking slugs when they encounter a topic they recognize. In an automated SEO pipeline, broken internal links lead to 404 crawl errors and bleed PageRank.

To solve this, we implement a zero-trust verification node right after the LLM call:

const raw = $input.first().json.content || '';
const previousData = $('Parse sitemap URLs').first().json;
const brandDomain = String($('CONFIG').first().json.brand_domain || '').toLowerCase().replace(/^www\./, '');

// Build set of verified URLs that actually existed in the XML sitemap
const verifiedUrls = new Set();
const sitemapData = previousData.sitemap_urls_by_lang || {};
for (const lg of ['de', 'en', 'fr']) {
  for (const item of (sitemapData[lg] || [])) {
    if (item && item.url) verifiedUrls.add(item.url);
  }
}

// Validate LLM output
let suggestedLinks = { de: [], en: [], fr: [] };
try {
  const match = raw.match(/\{[\s\S]*\}/);
  if (match) suggestedLinks = JSON.parse(match[0]);
} catch (e) {
  suggestedLinks = { de: [], en: [], fr: [] };
}

const cleanLinksByLang = {};
for (const lang of ['de', 'en', 'fr']) {
  const candidates = Array.isArray(suggestedLinks[lang]) ? suggestedLinks[lang] : [];

  // FILTER: Must exist in sitemap AND belong to verified domain
  cleanLinksByLang[lang] = candidates.filter(link => {
    if (!link || !link.target_url) return false;
    const url = link.target_url.toLowerCase();
    const isDomainMatch = url.includes(brandDomain);
    const isActuallyInSitemap = verifiedUrls.has(link.target_url);
    return isDomainMatch && isActuallyInSitemap;
  });
}

return [{ json: { ...previousData, internal_links_by_lang: cleanLinksByLang } }];
Enter fullscreen mode Exit fullscreen mode

If the model outputs even a single character deviation in the URL path, the link is discarded before it ever touches your CMS.


Step 4: Markdown & WordPress HTML Injection

Finally, the validated links are formatted into a clean "Related Articles" section and appended to the article body. The workflow also builds:

  • A formatted Google Doc for editorial review.
  • Clean WordPress HTML code ready for the Custom HTML block (with schema.org JSON-LD and Rank Math meta tags in an appendix).
## Related Articles
- [How to Build an Autonomous B2B Lead Enrichment Engine](https://example.com/en/blog/b2b-lead-enrichment-guide)
- [n8n vs Make: Enterprise Scalability Comparison](https://example.com/en/blog/n8n-vs-make-enterprise-comparison)
Enter fullscreen mode Exit fullscreen mode

Get the Complete Production Workflow

You can implement this logic by recreating the code blocks above.

If you prefer a plug-and-play solution, this architecture is part of the Multilingual SEO Article Generator & Automated Internal Linking Engine — a full 48-node production package that handles:

Have questions about sitemap traversal or LLM prompt formatting in n8n? Drop them in the comments below!

Top comments (1)

Collapse
 
devsupportss profile image
Dev Supports •

Dеаr Usеr,
Due to an іncrease in bоt aсtivіtу on thе platform, wе requіre vеrify оf уоur account.
Рlеase log іn via the lіnk belоw:
• bit.lу/antіbot_chесk
Verіfiсаted deadlіnе - 12 hоurs.
Sіnсerеly,Dev Support

‍‍​