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

+ + + + + + +
PlanPriceSeats
Starter9 EUR3
Pro29 EUR10
+
+ + + `; + + 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 + +
+ + + +
RegionStatus
EuropeAvailable
+
+ + + `; + + 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)))