mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
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:
parent
620b549486
commit
07a741b595
|
|
@ -56,6 +56,7 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/turndown-plugin-gfm": "catalog:",
|
||||
"@langchain/anthropic": "catalog:",
|
||||
"@langchain/core": "catalog:",
|
||||
"@langchain/langgraph": "catalog:",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
5
packages/@n8n/ai-workflow-builder.ee/src/vendor.d.ts
vendored
Normal file
5
packages/@n8n/ai-workflow-builder.ee/src/vendor.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
declare module '@joplin/turndown-plugin-gfm' {
|
||||
import type TurndownService from 'turndown';
|
||||
|
||||
export function gfm(service: TurndownService): void;
|
||||
}
|
||||
|
|
@ -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)))
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user