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:
- Recursively traverses WordPress / Rank Math XML sitemaps directly in memory.
- Buckets discovered URLs by language and extracts semantic slug titles.
- Uses Gemini 2.5 Flash to analyze relevance and generate contextual anchor text.
- 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
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 } }];
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": "..."}]
}
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 } }];
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)
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:
- Free Google Autocomplete long-tail research (no DataForSEO or Ahrefs fees).
- Gemini 2.5 Pro editorial core with anti-AI cadence rules (varied sentence length, zero corporate fluff).
- Parallel persona rewrites for German, English, and French.
Multipart Google Docs upload creating 6 docs per batch in seconds.
📦 Download on Gumroad (https://mannyverse767.gumroad.com/l/multilingual-seo-article-generator-n8n) (Use code
EARLYBIRDfor 20% off)
Have questions about sitemap traversal or LLM prompt formatting in n8n? Drop them in the comments below!
Top comments (1)
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