diff --git a/packages/@n8n/ai-workflow-builder.ee/package.json b/packages/@n8n/ai-workflow-builder.ee/package.json
index d4c1de6c349..7c5c112697a 100644
--- a/packages/@n8n/ai-workflow-builder.ee/package.json
+++ b/packages/@n8n/ai-workflow-builder.ee/package.json
@@ -56,6 +56,7 @@
}
},
"dependencies": {
+ "@joplin/turndown-plugin-gfm": "catalog:",
"@langchain/anthropic": "catalog:",
"@langchain/core": "catalog:",
"@langchain/langgraph": "catalog:",
diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/utils/test/web-fetch.utils.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/utils/test/web-fetch.utils.test.ts
index 8583ab249af..f5e66156f05 100644
--- a/packages/@n8n/ai-workflow-builder.ee/src/tools/utils/test/web-fetch.utils.test.ts
+++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/utils/test/web-fetch.utils.test.ts
@@ -189,6 +189,76 @@ describe('web-fetch.utils', () => {
expect(result.truncated).toBe(false);
});
+ it('should preserve tables as GFM Markdown', async () => {
+ const html = `
+
+
Pricing
+
+
+ Pricing
+
+ | Plan | Price | Seats |
+
+ | Starter | 9 EUR | 3 |
+ | Pro | 29 EUR | 10 |
+
+
+
+
+
+ `;
+
+ const result = await extractReadableContent(html, 'https://example.com/pricing');
+
+ expect(result.content).toContain(
+ '| Plan | Price | Seats |\n| --- | --- | --- |\n| Starter | 9 EUR | 3 |\n| Pro | 29 EUR | 10 |',
+ );
+ });
+
+ it('should use the first row as the header when a table contains only td cells', async () => {
+ const html = `
+
+ Regions
+
+
+
+ | Region | Status |
+ | Europe | Available |
+
+
+
+
+ `;
+
+ const result = await extractReadableContent(html, 'https://example.com/regions');
+
+ expect(result.content).toContain(
+ '| Region | Status |\n| --- | --- |\n| Europe | Available |',
+ );
+ expect(result.content).not.toContain('| | |');
+ });
+
+ it('should fall back to plain text when a table expands beyond the cell limit', async () => {
+ const html = `
+
+ Oversized Table
+
+
+
+ | Expanded header |
+ | Body value |
+
+
+
+
+ `;
+
+ const result = await extractReadableContent(html, 'https://example.com/oversized-table');
+
+ expect(result.content).toContain('Expanded header Body value');
+ expect(result.content).not.toContain('|');
+ });
+
it('should truncate content exceeding max chars', async () => {
// Generate content longer than WEB_FETCH_MAX_CONTENT_CHARS (30000)
const longText = 'A'.repeat(40_000);
diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/utils/web-fetch.utils.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/utils/web-fetch.utils.ts
index b0136fed15f..e884932fb32 100644
--- a/packages/@n8n/ai-workflow-builder.ee/src/tools/utils/web-fetch.utils.ts
+++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/utils/web-fetch.utils.ts
@@ -18,6 +18,9 @@ import {
/** Maximum number of redirects axios will follow before aborting. */
const WEB_FETCH_MAX_REDIRECTS = 5;
+/** Maximum number of table cells expanded by the GFM Turndown plugin. */
+const WEB_FETCH_MAX_TABLE_CELLS = 1_000;
+
// ============================================================================
// URL PROVENANCE
// ============================================================================
@@ -85,6 +88,64 @@ export interface ExtractedContent {
truncateReason?: string;
}
+function replaceOversizedTablesWithPlainText(root: Element): void {
+ let remainingCells = WEB_FETCH_MAX_TABLE_CELLS;
+
+ for (const table of root.querySelectorAll('table')) {
+ if (!root.contains(table)) continue;
+
+ let cellCount = 0;
+
+ for (const cell of table.querySelectorAll('th, td')) {
+ if (cell.closest('table') !== table) continue;
+
+ const colspan = Number(cell.getAttribute('colspan') ?? 1);
+ cellCount += Number.isNaN(colspan) || colspan < 1 ? 1 : Math.ceil(colspan);
+
+ if (cellCount > remainingCells) break;
+ }
+
+ if (cellCount <= remainingCells) {
+ remainingCells -= cellCount;
+ continue;
+ }
+
+ const plainText = Array.from(table.querySelectorAll('tr'))
+ .filter((row) => row.closest('table') === table)
+ .map((row) =>
+ Array.from(row.children)
+ .filter((cell) => cell.tagName === 'TH' || cell.tagName === 'TD')
+ .map((cell) => cell.textContent?.replace(/\s+/g, ' ').trim() ?? '')
+ .filter(Boolean)
+ .join(' '),
+ )
+ .filter(Boolean)
+ .join('\n');
+ table.replaceWith(root.ownerDocument.createTextNode(plainText));
+ }
+}
+
+function promoteFirstRowInHeaderlessTables(root: Element): void {
+ for (const table of root.querySelectorAll('table')) {
+ if (table.querySelector('th')) continue;
+
+ const firstRow = table.querySelector('tr');
+ if (!firstRow) continue;
+
+ for (const cell of Array.from(firstRow.children)) {
+ if (cell.tagName !== 'TD') continue;
+
+ const headerCell = root.ownerDocument.createElement('th');
+
+ for (const attribute of Array.from(cell.attributes)) {
+ headerCell.setAttribute(attribute.name, attribute.value);
+ }
+ headerCell.innerHTML = cell.innerHTML;
+ cell.replaceWith(headerCell);
+ }
+ }
+}
+
// ============================================================================
// HOST NORMALIZATION
// ============================================================================
@@ -232,8 +293,13 @@ export async function fetchUrl(
* Libraries are lazy-loaded to avoid pulling jsdom (~15-20MB) into memory at startup.
*/
export async function extractReadableContent(html: string, url: string): Promise {
- const [{ JSDOM, VirtualConsole }, { Readability }, { default: TurndownService }] =
- await Promise.all([import('jsdom'), import('@mozilla/readability'), import('turndown')]);
+ const [{ JSDOM, VirtualConsole }, { Readability }, { default: TurndownService }, { gfm }] =
+ await Promise.all([
+ import('jsdom'),
+ import('@mozilla/readability'),
+ import('turndown'),
+ import('@joplin/turndown-plugin-gfm'),
+ ]);
const virtualConsole = new VirtualConsole();
const dom = new JSDOM(html, { url, virtualConsole });
@@ -241,11 +307,16 @@ export async function extractReadableContent(html: string, url: string): Promise
const title = article?.title ?? '';
const articleHtml = article?.content ?? '';
+ const articleRoot = dom.window.document.createElement('div');
+ articleRoot.innerHTML = articleHtml;
+ replaceOversizedTablesWithPlainText(articleRoot);
+ promoteFirstRowInHeaderlessTables(articleRoot);
const turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
});
- let content = articleHtml ? turndownService.turndown(articleHtml) : '';
+ turndownService.use(gfm);
+ let content = articleHtml ? turndownService.turndown(articleRoot.innerHTML) : '';
let truncated = false;
let truncateReason: string | undefined;
diff --git a/packages/@n8n/ai-workflow-builder.ee/src/vendor.d.ts b/packages/@n8n/ai-workflow-builder.ee/src/vendor.d.ts
new file mode 100644
index 00000000000..3dc4ae44693
--- /dev/null
+++ b/packages/@n8n/ai-workflow-builder.ee/src/vendor.d.ts
@@ -0,0 +1,5 @@
+declare module '@joplin/turndown-plugin-gfm' {
+ import type TurndownService from 'turndown';
+
+ export function gfm(service: TurndownService): void;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2f69a99b136..85e9e831406 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1427,6 +1427,9 @@ importers:
packages/@n8n/ai-workflow-builder.ee:
dependencies:
+ '@joplin/turndown-plugin-gfm':
+ specifier: 'catalog:'
+ version: 1.0.64
'@langchain/anthropic':
specifier: 'catalog:'
version: 1.5.6(@langchain/core@1.2.8(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.221.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@6.46.0(@aws-sdk/credential-provider-node@3.972.80)(@smithy/signature-v4@5.7.2)(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))