feat(brandbook): anadir ingesta automatica de fuentes desde URL web en seccion Fuentes

This commit is contained in:
Marcos Fargas
2026-10-15 01:14:38 +02:00
parent bcde8d8198
commit e4a605bdbd
2 changed files with 139 additions and 3 deletions

View File

@@ -55,15 +55,17 @@ export async function POST(req: NextRequest) {
let filename = 'pegado.txt'; let filename = 'pegado.txt';
let text = ''; let text = '';
let url = '';
const ctype = req.headers.get('content-type') || ''; const ctype = req.headers.get('content-type') || '';
if (ctype.includes('multipart/form-data')) { if (ctype.includes('multipart/form-data')) {
const form = await req.formData(); const form = await req.formData();
const file = form.get('file'); const file = form.get('file');
const pasted = form.get('text'); const pasted = form.get('text');
if (typeof pasted === 'string' && pasted.trim()) { url = String(form.get('url') || '').trim();
if (!url && typeof pasted === 'string' && pasted.trim()) {
text = pasted; text = pasted;
filename = String(form.get('filename') || 'pegado.txt'); filename = String(form.get('filename') || 'pegado.txt');
} else if (file && typeof file === 'object' && 'arrayBuffer' in file) { } else if (!url && file && typeof file === 'object' && 'arrayBuffer' in file) {
const f = file as File; const f = file as File;
filename = f.name || 'upload.txt'; filename = f.name || 'upload.txt';
const lower = filename.toLowerCase(); const lower = filename.toLowerCase();
@@ -87,11 +89,80 @@ export async function POST(req: NextRequest) {
const body = (await req.json().catch(() => ({}))) as { const body = (await req.json().catch(() => ({}))) as {
text?: string; text?: string;
filename?: string; filename?: string;
url?: string;
}; };
url = String(body.url || '').trim();
text = String(body.text || ''); text = String(body.text || '');
filename = String(body.filename || 'pegado.txt'); filename = String(body.filename || 'pegado.txt');
} }
if (url) {
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return NextResponse.json(
{ error: 'La URL debe empezar por http:// o https://' },
{ status: 400 }
);
}
try {
const resp = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (compatible; 4VenturesBrandbot/1.0; +https://4ventures.es)',
Accept: 'text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8',
},
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) {
return NextResponse.json(
{ error: `Error al acceder a la URL: HTTP ${resp.status}` },
{ status: 400 }
);
}
const raw = await resp.text();
const clean = raw
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<noscript[\s\S]*?<\/noscript>/gi, '')
.replace(/<svg[\s\S]*?<\/svg>/gi, '')
.replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n\n')
.replace(/<br\s*[\/]?>/gi, '\n');
text = clean
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/[ \t]+/g, ' ')
.replace(/\n\s*\n\s*\n+/g, '\n\n')
.trim();
try {
const parsed = new URL(url);
const pathSegments = parsed.pathname.split('/').filter(Boolean);
const lastSeg = pathSegments[pathSegments.length - 1];
filename = lastSeg
? lastSeg.endsWith('.html')
? lastSeg
: `${lastSeg}.txt`
: `${parsed.hostname}.txt`;
} catch {
filename = 'fuente-url.txt';
}
} catch (err) {
return NextResponse.json(
{
error: `No se pudo conectar a la URL: ${
err instanceof Error ? err.message : String(err)
}`,
},
{ status: 400 }
);
}
}
if (!text.trim()) { if (!text.trim()) {
return NextResponse.json({ error: 'Sin texto' }, { status: 400 }); return NextResponse.json({ error: 'Sin texto' }, { status: 400 });
} }

View File

@@ -133,6 +133,8 @@ export default function BrandZone({
const [blogAnalysisResult, setBlogAnalysisResult] = useState<BlogAnalysisResult | null>(null); const [blogAnalysisResult, setBlogAnalysisResult] = useState<BlogAnalysisResult | null>(null);
const [showBlogAnalysisModal, setShowBlogAnalysisModal] = useState(false); const [showBlogAnalysisModal, setShowBlogAnalysisModal] = useState(false);
const [blogAnalyzeError, setBlogAnalyzeError] = useState<string | null>(null); const [blogAnalyzeError, setBlogAnalyzeError] = useState<string | null>(null);
const [sourceUrl, setSourceUrl] = useState('');
const [ingestingUrl, setIngestingUrl] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -297,6 +299,30 @@ export default function BrandZone({
} }
} }
async function ingestSourceUrl() {
if (!sourceUrl.trim()) return;
setIngestingUrl(true);
setErr(null);
try {
const fd = new FormData();
fd.append('url', sourceUrl.trim());
const r = await fetch('/api/brandbook/upload', { method: 'POST', body: fd });
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'Ingesta de URL falló');
setBook(j.brandbook);
setScore(j.completeness?.score ?? 0);
setMissing(j.completeness?.missing || []);
setExists(true);
setSourceUrl('');
setOkMsg(`Fuente web añadida con éxito: ${j.ingested}`);
setSection('fuentes');
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error al ingestar URL');
} finally {
setIngestingUrl(false);
}
}
async function uploadKit(file: File) { async function uploadKit(file: File) {
setKitUploading(true); setKitUploading(true);
setErr(null); setErr(null);
@@ -2560,10 +2586,49 @@ export default function BrandZone({
{section === 'fuentes' && ( {section === 'fuentes' && (
<div className="space-y-4"> <div className="space-y-4">
{/* Ingesta desde URL */}
<div className="rounded-xl border border-blue-200 bg-gradient-to-br from-blue-50/70 via-white to-sky-50/50 p-4 space-y-2.5">
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
<Globe className="w-4 h-4 text-blue-600" />
Ingestar fuente desde URL web (Estrategias, Artículos, Notions públicos o Dossiers)
</div>
<p className="text-xs text-gray-600">
Pega cualquier enlace web. El sistema descargará el texto, limpiará el HTML y lo añadirá automáticamente a la base de conocimiento de la marca (RAG).
</p>
<div className="flex gap-2 items-center">
<input
type="url"
placeholder="https://4ventures.es/bps/estrategia-linkedin-bps.html"
className="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-xs text-gray-900 shadow-xs focus:border-brand-500 focus:outline-hidden focus:ring-1 focus:ring-brand-500"
value={sourceUrl}
onChange={(e) => setSourceUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void ingestSourceUrl();
}
}}
/>
<button
type="button"
disabled={ingestingUrl || !sourceUrl.trim()}
onClick={() => void ingestSourceUrl()}
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3.5 py-2 text-xs font-semibold text-white hover:bg-blue-700 disabled:opacity-50 shrink-0 shadow-xs transition-colors"
>
{ingestingUrl ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Sparkles className="w-3.5 h-3.5" />
)}
{ingestingUrl ? 'Ingestando…' : 'Ingestar URL'}
</button>
</div>
</div>
<div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 p-4 space-y-3"> <div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-gray-800"> <div className="flex items-center gap-2 text-sm font-semibold text-gray-800">
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />
Subir o pegar brandbook / brief Subir archivo o pegar texto
</div> </div>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
.txt / .md / .csv / .json — o pega texto de PDF/DOCX. Detecta .txt / .md / .csv / .json — o pega texto de PDF/DOCX. Detecta