diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e2e09dd..c12ee24 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,7 +21,8 @@ const Licenses = lazy(() => import('./pages/Licenses')) const ExportImport = lazy(() => import('./pages/ExportImport')) const DrConsole = lazy(() => import('./pages/DrConsole')) const NetworkConsole = lazy(() => import('./pages/NetworkConsole')) -const CsapConsole = lazy(() => import('./pages/CsapConsole')) +const CsapConsole = lazy(() => import('./pages/CsapConsole')) +const ScrapingManager = lazy(() => import('./pages/ScrapingManager')) function Loading() { return ( @@ -59,6 +60,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 92cd661..bda358c 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -27,6 +27,7 @@ const NAV: NavItem[] = [ { label: 'Nginx 관리', icon: '', path: '/config/nginx' }, ]}, { label: '알림/리포트', icon: '🔔', path: '/notifications' }, + { label: '스크랩핑 봇', icon: '🕷️', path: '/scraping' }, { label: '라이선스 관리',icon: '🪪', path: '/licenses' }, { label: '데이터 연동', icon: '🔄', path: '/export-import' }, { label: '운영 관제', icon: '🛰️', children: [ diff --git a/frontend/src/pages/ScrapingManager.tsx b/frontend/src/pages/ScrapingManager.tsx new file mode 100644 index 0000000..d01fba0 --- /dev/null +++ b/frontend/src/pages/ScrapingManager.tsx @@ -0,0 +1,430 @@ +import { useState, useEffect, useCallback } from 'react' +import axios from 'axios' + +const API = import.meta.env.VITE_ITSM_API ?? 'http://localhost:9001' + +type Status = 'DRAFT' | 'PUBLISHED' | 'DELETED' | 'FAILED' + +interface ScrapingResult { + id: number + target_id: number | null + title: string | null + plain_text: string | null + url: string + status: Status + scraped_at: string + published_at: string | null + deleted_at: string | null + published_by: string | null + messenger_room: string | null + error_msg: string | null + scraped_by: string | null + created_at: string +} + +interface ScrapingTarget { + id: number + name: string + url: string + selector: string | null + schedule: string | null + is_active: boolean + last_scraped: string | null + note: string | null + created_by: string | null + created_at: string +} + +interface Stats { draft: number; published: number; deleted: number; failed: number; targets: number } + +const STATUS_COLOR: Record = { + DRAFT: '#64748b', + PUBLISHED: '#16a34a', + DELETED: '#dc2626', + FAILED: '#f59e0b', +} + +const STATUS_LABEL: Record = { + DRAFT: '대기', PUBLISHED: '게시됨', DELETED: '삭제됨', FAILED: '실패', +} + +function fmtDate(d: string | null) { + if (!d) return '—' + return new Date(d).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) +} + +function Badge({ status }: { status: Status }) { + return ( + {STATUS_LABEL[status]} + ) +} + +export default function ScrapingManager() { + const token = localStorage.getItem('guardia_token') ?? '' + const headers = { Authorization: `Bearer ${token}` } + + const [results, setResults] = useState([]) + const [targets, setTargets] = useState([]) + const [stats, setStats] = useState(null) + const [tab, setTab] = useState<'results' | 'targets'>('results') + const [statusFilter, setStatusFilter] = useState('ALL') + const [loading, setLoading] = useState(false) + const [runUrl, setRunUrl] = useState('') + const [runSelector, setRunSelector] = useState('') + const [running, setRunning] = useState(false) + const [selected, setSelected] = useState(null) + const [publishRoom, setPublishRoom] = useState('ops') + const [newTarget, setNewTarget] = useState({ name: '', url: '', selector: '', schedule: '', note: '' }) + + const load = useCallback(async () => { + setLoading(true) + try { + const [rRes, tRes, sRes] = await Promise.all([ + axios.get(`${API}/api/scraping/results`, { + params: { status: statusFilter === 'ALL' ? undefined : statusFilter, size: 50 }, + headers, + }), + axios.get(`${API}/api/scraping/targets`, { headers }), + axios.get(`${API}/api/scraping/stats`, { headers }), + ]) + setResults(rRes.data) + setTargets(tRes.data) + setStats(sRes.data) + } catch (e) { + console.error(e) + } finally { + setLoading(false) + } + }, [statusFilter, token]) + + useEffect(() => { load() }, [load]) + + async function handleRun() { + if (!runUrl.trim()) return + setRunning(true) + try { + const r = await axios.post(`${API}/api/scraping/run`, + { url: runUrl, selector: runSelector || null }, + { headers }) + alert(`스크랩 완료: #${r.data.id} — ${r.data.title || runUrl}`) + setRunUrl(''); setRunSelector('') + await load() + } catch (e: any) { + alert(`스크랩 실패: ${e.response?.data?.detail ?? e.message}`) + } finally { + setRunning(false) + } + } + + async function handlePublish(id: number) { + try { + await axios.post(`${API}/api/scraping/results/${id}/publish`, + { room: publishRoom }, { headers }) + alert(`#${id} 게시 완료`) + setSelected(null) + await load() + } catch (e: any) { + alert(e.response?.data?.detail ?? '게시 실패') + } + } + + async function handleDelete(id: number) { + if (!confirm(`#${id}를 삭제하시겠습니까? (원복 가능)`)) return + try { + await axios.delete(`${API}/api/scraping/results/${id}`, { headers }) + setSelected(null) + await load() + } catch (e: any) { + alert(e.response?.data?.detail ?? '삭제 실패') + } + } + + async function handleRestore(id: number) { + try { + await axios.post(`${API}/api/scraping/results/${id}/restore`, {}, { headers }) + alert(`#${id} 원복 완료`) + setSelected(null) + await load() + } catch (e: any) { + alert(e.response?.data?.detail ?? '원복 실패') + } + } + + async function handleAddTarget() { + if (!newTarget.name || !newTarget.url) return alert('이름과 URL은 필수입니다.') + try { + await axios.post(`${API}/api/scraping/targets`, + { ...newTarget, selector: newTarget.selector || null, schedule: newTarget.schedule || null }, + { headers }) + setNewTarget({ name: '', url: '', selector: '', schedule: '', note: '' }) + await load() + } catch (e: any) { + alert(e.response?.data?.detail ?? '등록 실패') + } + } + + async function handleDeleteTarget(id: number) { + if (!confirm('타겟을 삭제하면 연결된 결과도 모두 삭제됩니다.')) return + try { + await axios.delete(`${API}/api/scraping/targets/${id}`, { headers }) + await load() + } catch (e: any) { + alert(e.response?.data?.detail ?? '삭제 실패') + } + } + + const card = (label: string, val: number | undefined, color: string) => ( +
+
{val ?? 0}
+
{label}
+
+ ) + + return ( +
+
+

🕷️ 스크랩핑 관리

+ +
+ + {/* 통계 카드 */} +
+ {card('타겟', stats?.targets, '#6366f1')} + {card('대기(DRAFT)', stats?.draft, '#64748b')} + {card('게시됨', stats?.published, '#16a34a')} + {card('삭제됨', stats?.deleted, '#dc2626')} + {card('실패', stats?.failed, '#f59e0b')} +
+ + {/* 즉시 스크랩 */} +
+
즉시 스크랩
+
+ setRunUrl(e.target.value)} + placeholder="https://example.com" style={inputStyle} /> + setRunSelector(e.target.value)} + placeholder="CSS 셀렉터 (선택, 예: .article)" style={{ ...inputStyle, width: 200 }} /> + +
+
+ + {/* 탭 */} +
+ {(['results', 'targets'] as const).map(t => ( + + ))} +
+ + {/* 결과 탭 */} + {tab === 'results' && ( + <> +
+ {['ALL', 'DRAFT', 'PUBLISHED', 'DELETED', 'FAILED'].map(s => ( + + ))} +
+ +
+ + + + {['ID', '제목', 'URL', '상태', '수집일시', '게시일시', '조작'].map(h => ( + + ))} + + + + {results.length === 0 && ( + + )} + {results.map(r => ( + (e.currentTarget.style.background = '#f8fafc')} + onMouseLeave={e => (e.currentTarget.style.background = '')}> + + + + + + + + + ))} + +
{h}
결과 없음
#{r.id} +
+ {r.title || '—'} +
+ {r.plain_text &&
+ {r.plain_text.slice(0, 60)}... +
} +
+ + {r.url.replace(/^https?:\/\//, '').slice(0, 40)} + + {fmtDate(r.scraped_at)}{fmtDate(r.published_at)} +
+ + {r.status === 'DRAFT' && } + {r.status !== 'DELETED' && r.status !== 'PUBLISHED' && ( + + )} + {r.status === 'DELETED' && } +
+
+
+ + )} + + {/* 타겟 탭 */} + {tab === 'targets' && ( + <> +
+
새 타겟 등록
+
+ setNewTarget(p => ({ ...p, name: e.target.value }))} + placeholder="타겟 이름" style={{ ...inputStyle, width: 140 }} /> + setNewTarget(p => ({ ...p, url: e.target.value }))} + placeholder="URL" style={inputStyle} /> + setNewTarget(p => ({ ...p, selector: e.target.value }))} + placeholder="CSS 셀렉터 (선택)" style={{ ...inputStyle, width: 180 }} /> + setNewTarget(p => ({ ...p, schedule: e.target.value }))} + placeholder="크론 (예: 0 9 * * *)" style={{ ...inputStyle, width: 160 }} /> + +
+
+ +
+ + + + {['ID', '이름', 'URL', '셀렉터', '스케줄', '마지막 수집', '조작'].map(h => ( + + ))} + + + + {targets.length === 0 && ( + + )} + {targets.map(t => ( + + + + + + + + + + ))} + +
{h}
등록된 타겟 없음
#{t.id}{t.name} + + {t.url.slice(0, 40)} + + {t.selector || '—'}{t.schedule || '—'}{fmtDate(t.last_scraped)} + +
+
+ + )} + + {/* 상세 슬라이드 패널 */} + {selected && ( +
+
+ #{selected.id} 상세 + + +
+
+ + {selected.url}} /> + + + + {selected.error_msg && {selected.error_msg}} />} + {selected.plain_text && ( +
+
본문 미리보기
+
+ {selected.plain_text} +
+
+ )} +
+
+ {selected.status === 'DRAFT' && ( + <> + + + + + )} + {selected.status === 'DELETED' && ( + + )} +
+
+ )} +
+ ) +} + +function Info({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +const inputStyle: React.CSSProperties = { + flex: 1, minWidth: 200, padding: '7px 10px', + border: '1px solid #cbd5e1', borderRadius: 6, fontSize: 13, + outline: 'none', +} + +const btnPrimary: React.CSSProperties = { + padding: '7px 16px', background: '#4f6ef7', color: '#fff', + border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 13, fontWeight: 600, +} + +const btnSm = (color: string): React.CSSProperties => ({ + padding: '3px 10px', background: color, color: '#fff', + border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 11, fontWeight: 600, +})