feat(brandbook): anadir ingesta automatica de fuentes desde URL web en seccion Fuentes
This commit is contained in:
@@ -55,15 +55,17 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
let filename = 'pegado.txt';
|
||||
let text = '';
|
||||
let url = '';
|
||||
const ctype = req.headers.get('content-type') || '';
|
||||
if (ctype.includes('multipart/form-data')) {
|
||||
const form = await req.formData();
|
||||
const file = form.get('file');
|
||||
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;
|
||||
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;
|
||||
filename = f.name || 'upload.txt';
|
||||
const lower = filename.toLowerCase();
|
||||
@@ -87,11 +89,80 @@ export async function POST(req: NextRequest) {
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
text?: string;
|
||||
filename?: string;
|
||||
url?: string;
|
||||
};
|
||||
url = String(body.url || '').trim();
|
||||
text = String(body.text || '');
|
||||
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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /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()) {
|
||||
return NextResponse.json({ error: 'Sin texto' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -133,6 +133,8 @@ export default function BrandZone({
|
||||
const [blogAnalysisResult, setBlogAnalysisResult] = useState<BlogAnalysisResult | null>(null);
|
||||
const [showBlogAnalysisModal, setShowBlogAnalysisModal] = useState(false);
|
||||
const [blogAnalyzeError, setBlogAnalyzeError] = useState<string | null>(null);
|
||||
const [sourceUrl, setSourceUrl] = useState('');
|
||||
const [ingestingUrl, setIngestingUrl] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
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) {
|
||||
setKitUploading(true);
|
||||
setErr(null);
|
||||
@@ -2560,10 +2586,49 @@ export default function BrandZone({
|
||||
|
||||
{section === 'fuentes' && (
|
||||
<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="flex items-center gap-2 text-sm font-semibold text-gray-800">
|
||||
<Upload className="w-4 h-4" />
|
||||
Subir o pegar brandbook / brief
|
||||
Subir archivo o pegar texto
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
.txt / .md / .csv / .json — o pega texto de PDF/DOCX. Detecta
|
||||
|
||||
Reference in New Issue
Block a user