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 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(/&/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()) {
|
if (!text.trim()) {
|
||||||
return NextResponse.json({ error: 'Sin texto' }, { status: 400 });
|
return NextResponse.json({ error: 'Sin texto' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user