fix: Preserve tables in AI workflow builder web fetch (#38879)

Co-authored-by: oleg <me@olegivaniv.com>
Co-authored-by: Matsu <matias.huhta@n8n.io>
This commit is contained in:
Mayank SIngh 2026-09-22 07:33:07 +00:00 committed by GitHub
parent 620b549486
commit 07a741b595
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 153 additions and 3 deletions

View File

@ -56,6 +56,7 @@
}
},
"dependencies": {
"@joplin/turndown-plugin-gfm": "catalog:",
"@langchain/anthropic": "catalog:",
"@langchain/core": "catalog:",
"@langchain/langgraph": "catalog:",

View File

@ -189,6 +189,76 @@ describe('web-fetch.utils', () => {
expect(result.truncated).toBe(false);
});
it('should preserve tables as GFM Markdown', async () => {
const html = `
<html>
<head><title>Pricing</title></head>
<body>
<article>
<h1>Pricing</h1>
<table>
<thead><tr><th>Plan</th><th>Price</th><th>Seats</th></tr></thead>
<tbody>
<tr><td>Starter</td><td>9 EUR</td><td>3</td></tr>
<tr><td>Pro</td><td>29 EUR</td><td>10</td></tr>
</tbody>
</table>
</article>
</body>
</html>
`;
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 = `
<html>
<head><title>Regions</title></head>
<body>
<article>
<table>
<tr><td>Region</td><td>Status</td></tr>
<tr><td>Europe</td><td>Available</td></tr>
</table>
</article>
</body>
</html>
`;
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 = `
<html>
<head><title>Oversized Table</title></head>
<body>
<article>
<table>
<tr><th colspan="1001">Expanded header</th></tr>
<tr><td>Body value</td></tr>
</table>
</article>
</body>
</html>
`;
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);

View File

@ -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<ExtractedContent> {
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;

View File

@ -0,0 +1,5 @@
declare module '@joplin/turndown-plugin-gfm' {
import type TurndownService from 'turndown';
export function gfm(service: TurndownService): void;
}

View File

@ -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)))