n8n-nodes-capivision
Version:
OCR multiengine com visão apurada de capivara — Tesseract, OCR.space, AWS Textract e suporte a layout inteligente.
587 lines (551 loc) • 17.4 kB
text/typescript
import { IExecuteFunctions } from 'n8n-core';
import {
INodeExecutionData,
INodeType,
INodeTypeDescription,
IBinaryData,
} from 'n8n-workflow';
import { createWorker } from 'tesseract.js';
import { TextractClient, DetectDocumentTextCommand, AnalyzeDocumentCommand } from '@aws-sdk/client-textract';
import OpenAI from 'openai';
import axios from 'axios';
import { PDFExtract } from 'pdf.js-extract';
import { PDFDocument } from 'pdf-lib';
interface PdfPage {
content: Array<{ str: string }>;
}
interface PdfExtractResult {
pages: PdfPage[];
}
class OcrExtractor {
private pdfExtract = new PDFExtract();
async extractFromPdf(pdfBuffer: Buffer): Promise<string> {
try {
const data = await this.pdfExtract.extractBuffer(pdfBuffer) as PdfExtractResult;
return data.pages.map(page => page.content.map(item => item.str).join(' ')).join('\n\n');
} catch (error: any) {
throw new Error(`Erro ao extrair texto do PDF: ${error.message}`);
}
}
async validateAndProcessFile(binaryData: IBinaryData): Promise<{ type: string; data: Buffer }> {
try {
const mimeType = binaryData.mimeType || '';
if (!binaryData.data) {
throw new Error('Dados binários não encontrados');
}
const data = Buffer.from(binaryData.data, 'base64');
if (!data || data.length === 0) {
throw new Error('Falha ao converter dados base64 para buffer');
}
if (mimeType.startsWith('image/')) {
return { type: 'image', data };
} else if (mimeType === 'application/pdf') {
return { type: 'pdf', data };
} else {
throw new Error(`Tipo de arquivo não suportado: ${mimeType}. Use PDF ou imagem.`);
}
} catch (error: any) {
throw new Error(`Erro ao validar arquivo: ${error.message}`);
}
}
async extractText(result: any, engine: string, fileType: string): Promise<string> {
try {
switch (engine) {
case 'tesseract':
if (fileType === 'pdf') {
throw new Error('Tesseract.js não suporta PDF diretamente no n8n. Use OCR.space ou AWS Textract para PDFs.');
}
return result.text || '';
case 'ocrspace':
if (!result.ParsedResults || !result.ParsedResults[0]) {
throw new Error('OCR.space não retornou resultados válidos');
}
return result.ParsedResults[0].ParsedText || '';
case 'textract':
if (!result.Blocks) {
throw new Error('AWS Textract não retornou blocos de texto');
}
return result.Blocks
.filter((block: any) => block.BlockType === 'LINE')
.map((block: any) => block.Text)
.join('\n') || '';
default:
throw new Error(`Engine "${engine}" não suportada`);
}
} catch (error: any) {
throw new Error(`Erro ao extrair texto: ${error.message}`);
}
}
async extractJson(result: any, engine: string, layout: any): Promise<any> {
try {
const text = await this.extractText(result, engine, 'image');
if (Object.keys(layout).length === 0) {
return { text };
}
const structuredData: any = {};
for (const [field, coords] of Object.entries(layout)) {
const { x, y, w, h } = coords as any;
structuredData[field] = `Valor extraído para ${field}`;
}
return structuredData;
} catch (error: any) {
throw new Error(`Erro ao extrair JSON: ${error.message}`);
}
}
async extractCsv(result: any, engine: string, layout: any): Promise<string> {
try {
const jsonData = await this.extractJson(result, engine, layout);
if (Object.keys(layout).length === 0) {
return jsonData.text;
}
const headers = Object.keys(jsonData);
const values = Object.values(jsonData);
return `${headers.join(',')}\n${values.join(',')}`;
} catch (error: any) {
throw new Error(`Erro ao extrair CSV: ${error.message}`);
}
}
}
async function analyzeWithAI(text: string, credentials: any): Promise<string> {
try {
const openai = new OpenAI({
apiKey: credentials.apiKey,
});
const response = await openai.chat.completions.create({
model: 'gpt-4',
messages: [
{
role: 'system',
content: 'Você é um assistente especializado em análise de texto extraído por OCR. Analise o texto fornecido e forneça um resumo estruturado com os principais pontos e informações relevantes.',
},
{
role: 'user',
content: text,
},
],
temperature: 0.7,
});
return response.choices[0]?.message?.content || 'Não foi possível analisar o texto com IA.';
} catch (error: any) {
throw new Error(`Erro na análise com IA: ${error.message}`);
}
}
export class CapivisionOcr implements INodeType {
description: INodeTypeDescription = {
displayName: 'CAPIVISION OCR',
name: 'capivisionOcr',
icon: 'file:icon.svg',
group: ['transform'],
version: 1,
description: 'OCR multiengine com visão apurada de capivara',
defaults: {
name: 'CAPIVISION OCR',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'ocrSpaceApi',
required: true,
displayOptions: {
show: {
engine: ['ocrspace'],
},
},
},
{
name: 'awsTextractApi',
required: true,
displayOptions: {
show: {
engine: ['textract'],
},
},
},
{
name: 'openAiApi',
required: true,
displayOptions: {
show: {
treatmentMethod: ['ocr_ai'],
},
},
},
],
properties: [
{
displayName: 'Mecanismo OCR',
name: 'engine',
type: 'options',
options: [
{
name: 'Tesseract.js (apenas imagens)',
value: 'tesseract',
description: 'Melhor para imagens simples e texto bem definido',
},
{
name: 'OCR.space (imagens e PDF)',
value: 'ocrspace',
description: 'Suporta PDF e vários idiomas',
},
{
name: 'AWS Textract (imagens e PDF)',
value: 'textract',
description: 'Melhor para documentos complexos e formulários',
},
],
default: 'tesseract',
required: true,
},
{
displayName: 'Tipo de Entrada',
name: 'imageFormat',
type: 'options',
options: [
{
name: 'Binário',
value: 'binary',
},
{
name: 'Base64',
value: 'base64',
},
],
default: 'binary',
required: true,
},
{
displayName: 'Input Binário',
name: 'binaryPropertyName',
type: 'string',
default: 'data',
required: true,
displayOptions: {
show: {
imageFormat: ['binary'],
},
},
description: 'Nome do campo que contém a imagem/PDF',
},
{
displayName: 'String Base64',
name: 'base64String',
type: 'string',
typeOptions: {
rows: 4,
},
default: '',
required: true,
displayOptions: {
show: {
imageFormat: ['base64'],
},
},
description: 'String base64 da imagem (pode incluir ou não o cabeçalho data:image)',
},
{
displayName: 'Método de Tratamento',
name: 'treatmentMethod',
type: 'options',
options: [
{
name: 'Apenas OCR (Sem IA)',
value: 'ocr_only',
},
{
name: 'OCR + IA',
value: 'ocr_ai',
},
],
default: 'ocr_only',
required: true,
},
{
displayName: 'Mecanismo IA',
name: 'aiEngine',
type: 'options',
displayOptions: {
show: {
treatmentMethod: ['ocr_ai'],
},
},
options: [
{
name: 'ChatGPT',
value: 'chatgpt',
},
],
default: 'chatgpt',
required: true,
},
{
displayName: 'Modelo IA',
name: 'aiModel',
type: 'options',
displayOptions: {
show: {
treatmentMethod: ['ocr_ai'],
aiEngine: ['chatgpt'],
},
},
options: [
{
name: 'GPT-4o-mini',
value: 'gpt-4-mini',
},
{
name: 'GPT-4o',
value: 'gpt-4',
},
],
default: 'gpt-4-mini',
required: true,
},
{
displayName: 'Formato de Saída',
name: 'outputFormat',
type: 'options',
options: [
{
name: 'Texto Puro',
value: 'text',
},
{
name: 'JSON Estruturado',
value: 'json',
},
{
name: 'CSV',
value: 'csv',
},
],
default: 'text',
required: true,
},
{
displayName: 'Preset de Layout (opcional)',
name: 'layoutPreset',
type: 'json',
default: '{}',
required: false,
description: 'JSON com estrutura de coordenadas para extração',
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
try {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
const extractor = new OcrExtractor();
for (let i = 0; i < items.length; i++) {
let engine: string = '';
let treatmentMethod: string = '';
let outputFormat: string = '';
let fileType: string = '';
try {
engine = this.getNodeParameter('engine', i) as string;
outputFormat = this.getNodeParameter('outputFormat', i) as string;
treatmentMethod = this.getNodeParameter('treatmentMethod', i) as string;
const imageFormat = this.getNodeParameter('imageFormat', i) as string;
let imageData: Buffer;
let processedFile: { type: string; data: Buffer };
if (imageFormat === 'binary') {
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i) as string;
const binaryData = items[i].binary;
if (!binaryData) {
throw new Error('Nenhum dado binário encontrado!');
}
const binaryProperty = binaryData[binaryPropertyName];
if (!binaryProperty) {
throw new Error(`Nenhum dado binário encontrado no campo "${binaryPropertyName}"!`);
}
processedFile = await extractor.validateAndProcessFile(binaryProperty);
imageData = processedFile.data;
fileType = processedFile.type;
} else {
const base64String = this.getNodeParameter('base64String', i) as string;
if (!base64String) {
throw new Error('String base64 não fornecida!');
}
const base64Clean = base64String.replace(/^data:image\/[a-zA-Z+]+;base64,/, '');
imageData = Buffer.from(base64Clean, 'base64');
fileType = 'image';
}
let extractedText: string = '';
switch (engine) {
case 'tesseract':
if (fileType === 'pdf') {
throw new Error('Tesseract.js não suporta PDF diretamente no n8n. Use OCR.space ou AWS Textract para PDFs.');
}
console.log('Iniciando processamento Tesseract...');
console.log('Tamanho da imagem:', imageData.length, 'bytes');
try {
const worker = await createWorker({
langPath: 'https://tessdata.projectnaptha.com/4.0.0',
logger: m => console.log('Tesseract Log:', JSON.stringify(m)),
errorHandler: e => console.error('Tesseract Error:', e),
});
console.log('Worker Tesseract criado');
try {
await worker.loadLanguage('por');
console.log('Idioma carregado');
await worker.initialize('por');
console.log('Worker inicializado');
const result = await worker.recognize(imageData);
console.log('Reconhecimento concluído');
if (!result || !result.data) {
throw new Error('Resultado do Tesseract inválido');
}
extractedText = result.data.text || '';
if (!extractedText.trim()) {
throw new Error('Nenhum texto extraído da imagem');
}
console.log('Texto extraído com sucesso');
} finally {
await worker.terminate();
console.log('Worker terminado');
}
} catch (tesseractError: any) {
console.error('Erro detalhado do Tesseract:', tesseractError);
throw new Error(`Erro no processamento do Tesseract: ${tesseractError.message || 'Erro desconhecido'}\nStack: ${tesseractError.stack || 'Sem stack trace'}`);
}
break;
case 'ocrspace':
const credentials = await this.getCredentials('ocrSpaceApi');
const formData = new FormData();
formData.append('apikey', credentials.apiKey as string);
formData.append('language', 'por');
formData.append('isOverlayRequired', 'true');
formData.append('detectOrientation', 'true');
formData.append('scale', 'true');
formData.append('OCREngine', '2');
if (fileType === 'pdf') {
formData.append('file', new Blob([imageData], { type: 'application/pdf' }));
} else {
formData.append('file', new Blob([imageData]));
}
const response = await axios.post('https://api.ocr.space/parse/image', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
extractedText = response.data.ParsedResults[0].ParsedText || '';
break;
case 'textract':
const awsCredentials = await this.getCredentials('awsTextractApi');
const textract = new TextractClient({
region: awsCredentials.region as string,
credentials: {
accessKeyId: awsCredentials.accessKeyId as string,
secretAccessKey: awsCredentials.secretAccessKey as string,
},
});
if (fileType === 'pdf') {
const command = new AnalyzeDocumentCommand({
Document: {
Bytes: imageData,
},
FeatureTypes: ['FORMS', 'TABLES', 'QUERIES'],
});
const result = await textract.send(command);
extractedText = result.Blocks?.filter(block => block.BlockType === 'LINE')
.map(block => block.Text)
.join('\n') || '';
} else {
const command = new DetectDocumentTextCommand({
Document: {
Bytes: imageData,
},
});
const result = await textract.send(command);
extractedText = result.Blocks?.filter(block => block.BlockType === 'LINE')
.map(block => block.Text)
.join('\n') || '';
}
break;
}
let finalOutput: any = extractedText;
if (treatmentMethod === 'ocr_ai' && extractedText) {
try {
const credentials = await this.getCredentials('openAiApi');
const aiEngine = this.getNodeParameter('aiEngine', i) as string;
const aiModel = this.getNodeParameter('aiModel', i) as string;
if (aiEngine === 'chatgpt') {
const openai = new OpenAI({
apiKey: credentials.apiKey as string,
});
const aiResponse = await openai.chat.completions.create({
model: aiModel === 'gpt-4-mini' ? 'gpt-4-1106-preview' : 'gpt-4',
messages: [
{
role: 'system',
content: 'Você é um especialista em análise de documentos. Analise o texto fornecido e extraia as informações mais relevantes.',
},
{
role: 'user',
content: `Analise este texto e extraia as informações mais importantes:\n\n${extractedText}`,
},
],
temperature: 0.3,
});
const aiAnalysis = aiResponse.choices[0]?.message?.content || 'Não foi possível analisar o texto com IA.';
if (outputFormat === 'json') {
finalOutput = {
original_text: extractedText,
ai_analysis: aiAnalysis,
};
} else if (outputFormat === 'csv') {
finalOutput = `Texto Original,Análise IA\n"${extractedText.replace(/"/g, '""')}","${aiAnalysis.replace(/"/g, '""')}"`;
} else {
finalOutput = `=== Texto Original ===\n${extractedText}\n\n=== Análise IA ===\n${aiAnalysis}`;
}
}
} catch (aiError: any) {
throw new Error(`Erro na análise com IA: ${aiError.message}`);
}
}
returnData.push({
json: {
success: true,
timestamp: new Date().toISOString(),
engine,
fileType,
treatmentMethod,
outputFormat,
data: finalOutput,
metadata: {
processedAt: new Date().toISOString(),
engineVersion: {
tesseract: '4.1.1',
openai: '4.97.0',
},
},
},
});
} catch (error: any) {
console.error('Erro completo:', error);
returnData.push({
json: {
success: false,
timestamp: new Date().toISOString(),
error: {
message: error.message || 'Erro desconhecido',
type: error.name || 'ProcessingError',
stack: error.stack || 'Sem stack trace',
context: {
engine,
fileType,
treatmentMethod,
outputFormat,
originalError: error.toString(),
details: typeof error === 'object' ? JSON.stringify(error) : 'Erro não é um objeto'
},
},
},
});
}
}
return [returnData];
} catch (error: any) {
throw new Error(`Erro global na execução: ${error.message}\nDetalhes: ${error.stack}`);
}
}
}