Initial commit - Crawler SEO (with AI Agent prompt)
This commit is contained in:
@@ -0,0 +1,575 @@
|
||||
import sqlite3
|
||||
import json
|
||||
import glob
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
from typing import List, Optional
|
||||
import io
|
||||
import csv
|
||||
|
||||
app = FastAPI(title="Crawler SEO Dashboard API")
|
||||
|
||||
def get_db_conn(db_name: str):
|
||||
# Sprawdź czy db_name zawiera już katalog, jeśli nie - dodaj scans/
|
||||
if not db_name.startswith("scans/"):
|
||||
db_path = os.path.join("scans", db_name)
|
||||
else:
|
||||
db_path = db_name
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
raise HTTPException(status_code=404, detail=f"Baza danych nie istnieje: {db_path}")
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def get_dashboard():
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crawler SEO Dashboard</title>
|
||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@0.321.0/dist/umd/lucide.min.js"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; background: #0f172a; color: #f1f5f9; margin: 0; }
|
||||
.glass { background: rgba(30, 41, 59, 0.7); backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.1); }
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: #0f172a; }
|
||||
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
|
||||
.animate-fade-in { animation: fadeIn 0.4s ease-out; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
const SCHEMA_DEFS = {
|
||||
'Product': [
|
||||
{ field: 'name', label: 'Nazwa produktu', status: 'req' },
|
||||
{ field: 'image', label: 'Zdjęcie', status: 'req' },
|
||||
{ field: 'offers.price', label: 'Cena', status: 'req' },
|
||||
{ field: 'offers.priceCurrency', label: 'Waluta', status: 'req' },
|
||||
{ field: 'description', label: 'Opis', status: 'opt' },
|
||||
{ field: 'sku', label: 'SKU', status: 'warn' },
|
||||
{ field: 'brand.name', label: 'Marka', status: 'warn' },
|
||||
{ field: 'aggregateRating.ratingValue', label: 'Ocena', status: 'opt' },
|
||||
{ field: 'offers.availability', label: 'Dostępność', status: 'warn' }
|
||||
],
|
||||
'BreadcrumbList': [
|
||||
{ field: 'itemListElement', label: 'Elementy listy', status: 'req' }
|
||||
]
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [dbs, setDbs] = useState([]);
|
||||
const [selectedDb, setSelectedDb] = useState('');
|
||||
const [stats, setStats] = useState(null);
|
||||
const [pages, setPages] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [selectedPage, setSelectedPage] = useState(null);
|
||||
const [analysisData, setAnalysisData] = useState({ schemas: [], images: [] });
|
||||
const [activeTab, setActiveTab] = useState('schema');
|
||||
const [mainTab, setMainTab] = useState('pages');
|
||||
const [translations, setTranslations] = useState({ langs: [], data: [] });
|
||||
const [sortConfig, setSortConfig] = useState({ key: 'id', direction: 'desc' });
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/list-dbs').then(res => res.json()).then(data => {
|
||||
setDbs(data);
|
||||
if (data.length > 0) setSelectedDb(data[0]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDb) {
|
||||
setLoading(true);
|
||||
const endpoints = mainTab === 'pages'
|
||||
? [fetch(`/api/stats?db=${selectedDb}`), fetch(`/api/pages?db=${selectedDb}&status_type=${filter}`)]
|
||||
: [fetch(`/api/stats?db=${selectedDb}`), fetch(`/api/translations?db=${selectedDb}`)];
|
||||
|
||||
Promise.all(endpoints).then(async ([resStats, resData]) => {
|
||||
const s = await resStats.json();
|
||||
const d = await resData.json();
|
||||
setStats(s);
|
||||
if (mainTab === 'pages') setPages(d);
|
||||
else setTranslations(d);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}
|
||||
}, [selectedDb, filter, mainTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}, [pages, stats, selectedPage, sortConfig, loading, activeTab, mainTab, translations]);
|
||||
|
||||
const requestSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') direction = 'desc';
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
const getSortedPages = () => {
|
||||
if (!pages) return [];
|
||||
const sortable = [...pages];
|
||||
sortable.sort((a, b) => {
|
||||
let valA = a[sortConfig.key] ?? 0;
|
||||
let valB = b[sortConfig.key] ?? 0;
|
||||
if (sortConfig.key === 'schema_status') {
|
||||
valA = ((a.schema_critical || 0) * 100) + (a.schema_warnings || 0);
|
||||
valB = ((b.schema_critical || 0) * 100) + (b.schema_warnings || 0);
|
||||
}
|
||||
if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
return sortable;
|
||||
};
|
||||
|
||||
const viewAnalysis = (page) => {
|
||||
fetch(`/api/analysis/${page.id}?db=${selectedDb}`).then(res => res.json()).then(data => {
|
||||
setAnalysisData(data);
|
||||
setSelectedPage(page);
|
||||
setActiveTab('schema');
|
||||
});
|
||||
};
|
||||
|
||||
const getVal = (obj, path) => path.split('.').reduce((acc, part) => acc && acc[part], obj);
|
||||
const sortedPages = getSortedPages();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-6 max-w-7xl mx-auto animate-fade-in">
|
||||
<header className="flex flex-col md:flex-row justify-between items-start md:items-center mb-10 gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold bg-gradient-to-r from-blue-400 to-emerald-400 bg-clip-text text-transparent uppercase tracking-tight">
|
||||
SEO Audit Dashboard
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm mt-1 font-medium">Monitoring techniczny i audyt wielojęzyczności</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 bg-slate-800/50 p-1.5 rounded-2xl border border-slate-700">
|
||||
<span className="text-[10px] text-slate-500 font-black px-3 uppercase tracking-widest">Baza danych</span>
|
||||
<select value={selectedDb} onChange={(e) => setSelectedDb(e.target.value)} className="bg-transparent text-slate-200 p-2 pr-8 rounded-xl outline-none text-xs font-bold cursor-pointer">
|
||||
{dbs.map(db => <option key={db} value={db} className="bg-slate-900">{db}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-10">
|
||||
{[
|
||||
{ t: 'Strony', v: stats.total_pages, i: 'layers', c: 'blue' },
|
||||
{ t: 'Błędy HTTP', v: stats.errors, i: 'alert-circle', c: 'red' },
|
||||
{ t: 'Obiekty Schema', v: stats.schema_objects, i: 'code', c: 'emerald' },
|
||||
{ t: 'Błędy Tłumaczeń', v: stats.translation_errors || 0, i: 'globe', c: 'amber' }
|
||||
].map((s, idx) => (
|
||||
<div key={idx} className="glass p-5 rounded-2xl border border-white/5 shadow-lg flex items-center space-x-4">
|
||||
<div className={`p-2.5 rounded-xl bg-${s.c}-500/20 text-${s.c}-400`}><i data-lucide={s.i} className="w-5 h-5"></i></div>
|
||||
<div><p className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">{s.t}</p><p className="text-xl font-bold text-slate-100">{s.v}</p></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex bg-slate-800/30 rounded-2xl p-1 mb-8 w-fit border border-white/5">
|
||||
<button onClick={() => setMainTab('pages')} className={`px-6 py-2 rounded-xl text-xs font-bold transition-all ${mainTab==='pages' ? 'bg-blue-600 text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'}`}>AUDYT TECHNICZNY</button>
|
||||
<button onClick={() => setMainTab('translations')} className={`px-6 py-2 rounded-xl text-xs font-bold transition-all ${mainTab==='translations' ? 'bg-blue-600 text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'}`}>AUDYT TŁUMACZEŃ</button>
|
||||
</div>
|
||||
|
||||
{mainTab === 'pages' ? (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[{id:'all',l:'Wszystkie'},{id:'error',l:'Błędy'},{id:'slow',l:'Wolne'},{id:'images',l:'Obrazy'}].map(f => (
|
||||
<button key={f.id} onClick={() => setFilter(f.id)} className={`px-5 py-1.5 rounded-xl text-xs font-bold transition-all ${filter===f.id ? 'bg-blue-600 text-white shadow-lg' : 'glass text-slate-500 hover:text-slate-300'}`}>
|
||||
{f.l.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<a href={`/api/export-csv?db=${selectedDb}&status_type=${filter}`} download className="bg-emerald-600/20 hover:bg-emerald-600 text-emerald-400 hover:text-white px-5 py-1.5 rounded-xl text-xs font-bold transition-all flex items-center space-x-2 border border-emerald-600/30">
|
||||
<i data-lucide="download" className="w-4 h-4"></i><span>EKSPORTUJ CSV</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-3xl border border-white/5 shadow-2xl">
|
||||
<div className="overflow-auto max-h-[75vh]">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-slate-900 sticky top-0 z-10 shadow-md">
|
||||
<tr className="text-[10px] font-black text-slate-400 uppercase tracking-[0.15em]">
|
||||
{[{k:'status',l:'Status'},{k:'lang',l:'Język'},{k:'url',l:'URL'},{k:'total_time',l:'Czas'},{k:'schema_status',l:'Schema'}].map(col => (
|
||||
<th key={col.k} onClick={()=>requestSort(col.k)} className="p-4 cursor-pointer hover:bg-white/5 transition">
|
||||
<div className="flex items-center space-x-1"><span>{col.l}</span>{sortConfig.key === col.k && <i data-lucide={sortConfig.direction==='asc'?'chevron-up':'chevron-down'} className="w-3 h-3 text-blue-400"></i>}</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="p-4 text-right">Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm">
|
||||
{loading ? (
|
||||
<tr><td colSpan="6" className="p-20 text-center text-slate-600 font-bold animate-pulse uppercase tracking-[0.2em]">Pobieranie danych...</td></tr>
|
||||
) : sortedPages.map(page => (
|
||||
<tr key={page.id} className="border-t border-white/5 hover:bg-white/[0.02] transition-colors group">
|
||||
<td className="p-4"><span className={`px-2 py-0.5 rounded text-[10px] font-black ${page.status===200 ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>{page.status || 'ERROR'}</span></td>
|
||||
<td className="p-4 font-black text-[10px] text-slate-500 uppercase">{page.lang || '??'}</td>
|
||||
<td className="p-4 max-w-sm"><div className="truncate font-medium"><a href={page.url} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:text-blue-300 transition-colors underline decoration-blue-400/30 hover:decoration-blue-400 underline-offset-4">{page.url}</a></div><div className="text-[10px] text-slate-500 mt-0.5 truncate italic">Źródło: {page.source_url ? <a href={page.source_url} target="_blank" rel="noopener noreferrer" className="hover:text-slate-300 transition-colors">{page.source_url}</a> : 'Bezpośrednie'}</div></td>
|
||||
<td className="p-4 text-slate-400 tabular-nums">{page.total_time?.toFixed(3)}s</td>
|
||||
<td className="p-4">
|
||||
{page.schema_critical > 0 ? <span className="text-red-500 text-[10px] font-black uppercase">Krytyczny</span> :
|
||||
page.schema_warnings > 0 ? <span className="text-amber-500 text-[10px] font-black uppercase">Ostrzeżenia</span> : <span className="text-emerald-500 text-[10px] font-black uppercase">OK</span>}
|
||||
</td>
|
||||
<td className="p-4 text-right"><button onClick={() => viewAnalysis(page)} className="bg-blue-500/10 hover:bg-blue-600 text-blue-400 hover:text-white px-3 py-1.5 rounded-lg text-[10px] font-black transition-all uppercase">Analiza</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass rounded-3xl border border-white/5 shadow-2xl animate-fade-in">
|
||||
<div className="overflow-auto max-h-[75vh]">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-slate-900 text-[10px] font-black text-slate-400 uppercase tracking-widest sticky top-0 z-10 shadow-md">
|
||||
<tr>
|
||||
<th className="p-5 w-40">SKU</th>
|
||||
<th className="p-5 w-40">POLE</th>
|
||||
{translations?.langs?.map(l => <th key={l} className="p-5 text-center">{l.toUpperCase()}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm">
|
||||
{loading ? (
|
||||
<tr><td colSpan={2 + (translations?.langs?.length || 0)} className="p-20 text-center text-slate-600 font-bold animate-pulse uppercase tracking-[0.2em]">Analiza tłumaczeń...</td></tr>
|
||||
) : translations?.data?.length > 0 ? translations.data.map((t, idx) => {
|
||||
const isFirstOfSku = idx === 0 || translations.data[idx-1].sku !== t.sku;
|
||||
return (
|
||||
<tr key={idx} className={`hover:bg-white/[0.02] transition-colors ${isFirstOfSku ? 'border-t-2 border-t-white/10' : 'border-t border-white/5 border-dashed'}`}>
|
||||
<td className="p-5 font-bold tabular-nums">
|
||||
{isFirstOfSku ? <a href={t.url} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:text-blue-300 transition-colors underline decoration-blue-400/30 hover:decoration-blue-400 underline-offset-4">{t.sku}</a> : null}
|
||||
</td>
|
||||
<td className="p-5 uppercase text-[10px] font-black text-slate-400">{t.field}</td>
|
||||
{translations.langs.map(l => (
|
||||
<td key={l} className="p-5 text-center">
|
||||
{t[l] === 'V' ? <i data-lucide="check-circle" className="w-4 h-4 text-emerald-500/70 mx-auto"></i> : <i data-lucide="x-circle" className="w-5 h-5 text-red-500 mx-auto drop-shadow-md"></i>}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}) : (
|
||||
<tr><td colSpan={10} className="p-20 text-center text-slate-600 font-bold uppercase tracking-widest">Brak danych o tłumaczeniach.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPage && (
|
||||
<div className="fixed inset-0 bg-slate-950/90 backdrop-blur-md flex items-center justify-center p-4 z-50 animate-fade-in">
|
||||
<div className="glass w-full max-w-6xl max-h-[90vh] overflow-hidden flex flex-col rounded-[2.5rem] border border-white/10 shadow-2xl">
|
||||
<div className="p-6 border-b border-white/5 flex justify-between items-center bg-slate-900/50">
|
||||
<div><p className="text-blue-400 text-[10px] font-black uppercase tracking-widest mb-1">Pełny audyt strony</p><h2 className="text-lg font-bold text-slate-100 truncate max-w-2xl">{selectedPage.url}</h2></div>
|
||||
<button onClick={() => setSelectedPage(null)} className="p-2 hover:bg-white/10 rounded-xl transition-colors"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div className="flex bg-slate-900/50 border-b border-white/5">
|
||||
<button onClick={()=>setActiveTab('schema')} className={`px-8 py-3 text-[10px] font-black uppercase tracking-widest transition-all ${activeTab==='schema'?'text-blue-400 border-b-2 border-blue-400 bg-blue-400/5':'text-slate-500 hover:text-slate-300'}`}>Schema.org</button>
|
||||
<button onClick={()=>setActiveTab('metadata')} className={`px-8 py-3 text-[10px] font-black uppercase tracking-widest transition-all ${activeTab==='metadata'?'text-purple-400 border-b-2 border-purple-400 bg-purple-400/5':'text-slate-500 hover:text-slate-300'}`}>Metadane SEO</button>
|
||||
<button onClick={()=>setActiveTab('images')} className={`px-8 py-3 text-[10px] font-black uppercase tracking-widest transition-all ${activeTab==='images'?'text-emerald-400 border-b-2 border-emerald-400 bg-emerald-400/5':'text-slate-500 hover:text-slate-300'}`}>Audyt Grafiki</button>
|
||||
</div>
|
||||
<div className="p-6 overflow-y-auto">
|
||||
{activeTab === 'schema' ? (
|
||||
<div>
|
||||
<div className="mb-10">
|
||||
<h3 className="text-slate-500 text-[10px] font-black mb-4 uppercase tracking-[0.2em] border-b border-white/5 pb-2">I. Audyt pól Schema.org</h3>
|
||||
{analysisData.schemas.length > 0 ? analysisData.schemas.map((s, i) => {
|
||||
const fields = SCHEMA_DEFS[s.type] || [];
|
||||
return (
|
||||
<div key={i} className="mb-6 glass rounded-2xl overflow-hidden border border-white/5">
|
||||
<div className="px-4 py-2 bg-white/5 flex items-center space-x-2 text-[10px] font-black uppercase text-slate-400"><i data-lucide="file-json" className="w-3 h-3"></i><span>{s.type}</span></div>
|
||||
<table className="w-full text-[10px] border-collapse">
|
||||
<tbody>
|
||||
{fields.map((f, fi) => {
|
||||
const val = getVal(s.data, f.field);
|
||||
const ex = val !== undefined && val !== null && val !== '';
|
||||
return (
|
||||
<tr key={fi} className="border-t border-white/5">
|
||||
<td className="p-2.5 text-slate-400 w-1/3">{f.label}</td>
|
||||
<td className="p-2.5 text-slate-200 truncate max-w-[200px] font-medium">{ex ? (typeof val==='object'?'Obiekt':String(val)):'—'}</td>
|
||||
<td className="p-2.5 text-right font-black uppercase">{ex ? <span className="text-emerald-500">OK</span> : f.status==='req' ? <span className="text-red-500">Wymagane</span> : f.status==='warn' ? <span className="text-amber-500">Zalecane</span> : <span className="text-slate-600">Opcjonalne</span>}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}) : <p className="text-center py-10 text-slate-600 font-bold uppercase tracking-widest text-[10px]">Brak danych strukturalnych</p>}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-slate-500 text-[10px] font-black mb-4 uppercase tracking-[0.2em] border-b border-white/5 pb-2">II. Kod JSON-LD</h3>
|
||||
{analysisData.schemas.map((s, i) => (
|
||||
<pre key={i} className="bg-slate-950/80 p-5 rounded-xl overflow-x-auto text-[11px] text-blue-200/70 border border-white/5 font-mono mb-4 last:mb-0">{JSON.stringify(s.data, null, 2)}</pre>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'metadata' ? (
|
||||
<div>
|
||||
<h3 className="text-slate-500 text-[10px] font-black mb-4 uppercase tracking-[0.2em] border-b border-white/5 pb-2">Metadane SEO</h3>
|
||||
<div className="glass rounded-2xl overflow-hidden border border-white/5 p-5 text-sm text-slate-300">
|
||||
<div className="mb-4">
|
||||
<span className="block text-[10px] font-black uppercase text-slate-500 mb-1">Tag Title</span>
|
||||
<div className="font-bold text-slate-100 bg-slate-900/50 p-3 rounded-xl border border-white/5">{selectedPage.title || 'Brak tagu <title>'}</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<span className="block text-[10px] font-black uppercase text-slate-500 mb-1">Meta Description</span>
|
||||
<div className="font-medium text-slate-300 bg-slate-900/50 p-3 rounded-xl border border-white/5">{selectedPage.meta_desc || 'Brak description'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[10px] font-black uppercase text-slate-500 mb-1">Link Canonical</span>
|
||||
<div className="font-mono text-xs text-blue-400 bg-slate-900/50 p-3 rounded-xl border border-white/5 truncate">{selectedPage.canonical || 'Brak canonical'}</div>
|
||||
{selectedPage.canonical && selectedPage.canonical !== selectedPage.url && <div className="mt-2 text-[10px] text-amber-500 font-bold uppercase"><i data-lucide="alert-triangle" className="w-3 h-3 inline mr-1"></i>Canonical wskazuje na inną stronę!</div>}
|
||||
{selectedPage.canonical && selectedPage.canonical === selectedPage.url && <div className="mt-2 text-[10px] text-emerald-500 font-bold uppercase"><i data-lucide="check-circle" className="w-3 h-3 inline mr-1"></i>Samoodwołujący (Zgodny z URL)</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h3 className="text-slate-500 text-[10px] font-black mb-4 uppercase tracking-[0.2em] border-b border-white/5 pb-2">Audyt optymalizacji obrazów</h3>
|
||||
<div className="glass rounded-2xl overflow-hidden border border-white/5">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-white/5 text-[9px] font-black text-slate-500 uppercase tracking-widest">
|
||||
<tr><th className="p-3">Podgląd</th><th className="p-3">Atrybut ALT</th><th className="p-3">Format Modern</th><th className="p-3 text-right">Status</th></tr>
|
||||
</thead>
|
||||
<tbody className="text-[10px]">
|
||||
{analysisData.images.length > 0 ? analysisData.images.map((img, i) => (
|
||||
<tr key={i} className="border-t border-white/5 hover:bg-white/[0.02]">
|
||||
<td className="p-3"><img src={img.img_url} className="w-10 h-10 object-cover rounded bg-slate-800" onError={(e)=>e.target.src='https://via.placeholder.com/40'} /></td>
|
||||
<td className="p-3"><span className={img.alt==='[BRAK]'?'text-red-400 font-bold':'text-slate-300'}>{img.alt}</span></td>
|
||||
<td className="p-3">{img.is_modern ? <span className="text-emerald-400 font-bold">TAK (Bezpośrednio)</span> : img.has_modern_source ? <span className="text-blue-400 font-bold">TAK (Picture/Srcset)</span> : <span className="text-amber-500 font-bold">NIE (Stary format)</span>}</td>
|
||||
<td className="p-3 text-right">{img.alt!=='[BRAK]' && (img.is_modern || img.has_modern_source) ? <span className="text-emerald-500 font-black">ZOPTYMALIZOWANO</span> : <span className="text-amber-500 font-black text-[9px]">DO POPRAWY</span>}</td>
|
||||
</tr>
|
||||
)) : <tr><td colSpan="4" className="p-10 text-center text-slate-600 font-bold uppercase">Nie znaleziono obrazów</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@app.get("/api/list-dbs")
|
||||
def list_dbs():
|
||||
dbs = glob.glob("scans/*.db")
|
||||
# Zwracamy same nazwy plików dla ładniejszego widoku w select
|
||||
return sorted([os.path.basename(db) for db in dbs], reverse=True)
|
||||
|
||||
@app.get("/api/stats")
|
||||
def get_stats(db: str):
|
||||
conn = get_db_conn(db)
|
||||
cursor = conn.cursor()
|
||||
stats = {"total_pages": 0, "errors": 0, "avg_time": 0, "schema_objects": 0, "img_issues": 0, "translation_errors": 0}
|
||||
try:
|
||||
stats["total_pages"] = cursor.execute("SELECT COUNT(*) FROM pages").fetchone()[0]
|
||||
stats["errors"] = cursor.execute("SELECT COUNT(*) FROM pages WHERE status != 200 AND status != 0").fetchone()[0]
|
||||
stats["avg_time"] = cursor.execute("SELECT AVG(total_time) FROM pages WHERE total_time > 0").fetchone()[0] or 0
|
||||
stats["schema_objects"] = cursor.execute("SELECT COUNT(*) FROM structured_data").fetchone()[0]
|
||||
except: pass
|
||||
try:
|
||||
img_stats = cursor.execute("SELECT SUM(images_no_alt), SUM(images_no_webp) FROM pages").fetchone()
|
||||
stats["img_issues"] = (img_stats[0] or 0) + (img_stats[1] or 0)
|
||||
except: pass
|
||||
try:
|
||||
stats["translation_errors"] = cursor.execute("SELECT COUNT(*) FROM translation_audit").fetchone()[0]
|
||||
except: pass
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
@app.get("/api/pages")
|
||||
def get_pages(db: str, status_type: Optional[str] = "all"):
|
||||
conn = get_db_conn(db)
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
query = "SELECT * FROM pages"
|
||||
try:
|
||||
cursor.execute("SELECT images_no_alt FROM pages LIMIT 1")
|
||||
has_img_cols = True
|
||||
except: has_img_cols = False
|
||||
|
||||
if status_type == "error": query += " WHERE status != 200 AND status != 0"
|
||||
elif status_type == "noindex": query += " WHERE index_status LIKE 'Noindex%'"
|
||||
elif status_type == "slow": query += " WHERE total_time > 1.5"
|
||||
elif status_type == "images" and has_img_cols: query += " WHERE images_no_alt > 0 OR images_no_webp > 0"
|
||||
|
||||
query += " ORDER BY id DESC LIMIT 1000"
|
||||
pages = cursor.execute(query).fetchall()
|
||||
return [dict(p) for p in pages]
|
||||
except: return []
|
||||
finally: conn.close()
|
||||
|
||||
@app.get("/api/translations")
|
||||
def get_translations(db: str):
|
||||
conn = get_db_conn(db)
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
try:
|
||||
cursor.execute("SELECT title, meta_desc FROM pages LIMIT 1")
|
||||
has_meta = True
|
||||
except: has_meta = False
|
||||
|
||||
if has_meta:
|
||||
query = """
|
||||
SELECT s.sku, p.lang, s.full_json, MIN(p.url) as url, MAX(p.title) as title, MAX(p.meta_desc) as meta_desc
|
||||
FROM structured_data s
|
||||
JOIN pages p ON s.page_id = p.id
|
||||
WHERE s.sku IS NOT NULL AND s.sku != 'None' AND s.sku != '' AND s.schema_type LIKE '%Product%'
|
||||
GROUP BY s.sku, p.lang
|
||||
"""
|
||||
else:
|
||||
query = """
|
||||
SELECT s.sku, p.lang, s.full_json, MIN(p.url) as url, '' as title, '' as meta_desc
|
||||
FROM structured_data s
|
||||
JOIN pages p ON s.page_id = p.id
|
||||
WHERE s.sku IS NOT NULL AND s.sku != 'None' AND s.sku != '' AND s.schema_type LIKE '%Product%'
|
||||
GROUP BY s.sku, p.lang
|
||||
"""
|
||||
rows = cursor.execute(query).fetchall()
|
||||
|
||||
sku_map = {}
|
||||
langs_set = set()
|
||||
|
||||
for r in rows:
|
||||
sku = str(r['sku']).strip()
|
||||
lang = str(r['lang']).lower().strip()
|
||||
if '-' in lang: lang = lang.split('-')[0]
|
||||
langs_set.add(lang)
|
||||
|
||||
try: data = json.loads(r['full_json'])
|
||||
except: continue
|
||||
|
||||
obj = {}
|
||||
if isinstance(data, list): obj = next((item for item in data if 'Product' in str(item.get('@type', ''))), {})
|
||||
else: obj = data if 'Product' in str(data.get('@type', '')) else {}
|
||||
|
||||
name = obj.get('name', '').strip()
|
||||
desc = obj.get('description', '').strip()
|
||||
title = (r['title'] or '').strip()
|
||||
meta_desc = (r['meta_desc'] or '').strip()
|
||||
|
||||
slug = ''
|
||||
if r['url']:
|
||||
parts = r['url'].rstrip('/').split('/')
|
||||
if parts: slug = parts[-1].split('?')[0].split('#')[0]
|
||||
|
||||
if sku not in sku_map: sku_map[sku] = {'langs': {}, 'url': r['url']}
|
||||
sku_map[sku]['langs'][lang] = {
|
||||
'nazwa': name, 'opis': desc,
|
||||
'nazwa seo': title, 'opis seo': meta_desc, 'slug': slug
|
||||
}
|
||||
|
||||
all_langs = sorted(list(langs_set))
|
||||
if 'pl' in all_langs: all_langs.remove('pl')
|
||||
|
||||
results = []
|
||||
fields = ['nazwa', 'opis', 'nazwa seo', 'opis seo', 'slug']
|
||||
|
||||
for sku, info in sku_map.items():
|
||||
if 'pl' not in info['langs']: continue
|
||||
|
||||
pl_data = info['langs']['pl']
|
||||
sku_has_errors = False
|
||||
sku_rows = []
|
||||
|
||||
for field in fields:
|
||||
pl_val = pl_data.get(field, '')
|
||||
if not pl_val: continue
|
||||
|
||||
row = {'sku': sku, 'field': field, 'url': info['url']}
|
||||
for lang in all_langs:
|
||||
l_val = info['langs'].get(lang, {}).get(field, '')
|
||||
if not l_val or l_val == pl_val:
|
||||
row[lang] = 'X'
|
||||
sku_has_errors = True
|
||||
else:
|
||||
row[lang] = 'V'
|
||||
sku_rows.append(row)
|
||||
|
||||
if sku_has_errors:
|
||||
results.extend(sku_rows)
|
||||
|
||||
return {"langs": all_langs, "data": results}
|
||||
except Exception as e:
|
||||
print(f"Error in translations: {e}")
|
||||
return {"langs": [], "data": []}
|
||||
finally: conn.close()
|
||||
|
||||
@app.get("/api/analysis/{page_id}")
|
||||
def get_analysis(db: str, page_id: int):
|
||||
conn = get_db_conn(db)
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
schemas = cursor.execute("SELECT schema_type, full_json FROM structured_data WHERE page_id = ?", (page_id,)).fetchall()
|
||||
try: images = cursor.execute("SELECT img_url, alt, is_modern, has_modern_source FROM images_audit WHERE page_id = ?", (page_id,)).fetchall()
|
||||
except: images = []
|
||||
schema_list = []
|
||||
for s in schemas:
|
||||
try: schema_list.append({"type": s["schema_type"], "data": json.loads(s["full_json"])})
|
||||
except: schema_list.append({"type": s["schema_type"], "data": s["full_json"]})
|
||||
return {"schemas": schema_list, "images": [dict(img) for img in images]}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.get("/api/export-csv")
|
||||
def export_csv(db: str, status_type: Optional[str] = "all"):
|
||||
conn = get_db_conn(db)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("SELECT images_no_alt FROM pages LIMIT 1")
|
||||
has_img_cols = True
|
||||
except: has_img_cols = False
|
||||
|
||||
query = "SELECT * FROM pages"
|
||||
if status_type == "error": query += " WHERE status != 200 AND status != 0"
|
||||
elif status_type == "noindex": query += " WHERE index_status LIKE 'Noindex%'"
|
||||
elif status_type == "slow": query += " WHERE total_time > 1.5"
|
||||
elif status_type == "images" and has_img_cols: query += " WHERE images_no_alt > 0 OR images_no_webp > 0"
|
||||
query += " ORDER BY id DESC"
|
||||
pages = cursor.execute(query).fetchall()
|
||||
conn.close()
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, delimiter=';')
|
||||
if pages:
|
||||
keys = list(dict(pages[0]).keys())
|
||||
writer.writerow([k.upper() for k in keys])
|
||||
for p in pages:
|
||||
writer.writerow([dict(p).get(k, '') for k in keys])
|
||||
else:
|
||||
writer.writerow(['BRAK DANYCH'])
|
||||
output.seek(0)
|
||||
filename = f"raport_seo_{status_type}_{db.replace('.db', '')}.csv"
|
||||
return StreamingResponse(io.BytesIO(output.getvalue().encode('utf-8-sig')), media_type="text/csv", headers={"Content-Disposition": f"attachment; filename={filename}"})
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="127.0.0.1", port=8000)
|
||||
Reference in New Issue
Block a user