크롤러 (utils/yakkok_crawler.py): - yakkok.com에서 제품 검색 및 이미지 추출 - MSSQL 오늘 판매 품목 자동 조회 - base64 변환 후 SQLite 저장 - CLI 지원 (--today, --product) DB (product_images.db): - 바코드, 제품명, 이미지(base64), 상태 저장 - 크롤링 로그 테이블 어드민 페이지 (/admin/product-images): - 이미지 목록/검색/필터 - 통계 (성공/실패/대기) - 상세 보기/삭제 - 오늘 판매 제품 일괄 크롤링 API: - GET /api/admin/product-images - GET /api/admin/product-images/<barcode> - POST /api/admin/product-images/crawl-today - DELETE /api/admin/product-images/<barcode>
576 lines
20 KiB
HTML
576 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>제품 이미지 관리 - yakkok 크롤러</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: 'Noto Sans KR', sans-serif;
|
|
background: linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%);
|
|
min-height: 100vh;
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 24px;
|
|
flex-wrap: wrap;
|
|
gap: 16px;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 24px;
|
|
background: linear-gradient(135deg, #a855f7, #6366f1);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.btn {
|
|
padding: 10px 20px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, #8b5cf6, #6366f1);
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.4);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: rgba(255,255,255,0.1);
|
|
color: #e0e0e0;
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: rgba(255,255,255,0.2);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
|
color: white;
|
|
}
|
|
|
|
.stats-bar {
|
|
display: flex;
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.stat-card {
|
|
background: rgba(255,255,255,0.05);
|
|
backdrop-filter: blur(10px);
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 12px;
|
|
padding: 16px 24px;
|
|
min-width: 120px;
|
|
}
|
|
|
|
.stat-card .value {
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
color: #a855f7;
|
|
}
|
|
|
|
.stat-card .label {
|
|
font-size: 12px;
|
|
color: #9ca3af;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.stat-card.success .value { color: #10b981; }
|
|
.stat-card.failed .value { color: #ef4444; }
|
|
.stat-card.pending .value { color: #f59e0b; }
|
|
|
|
.filters {
|
|
display: flex;
|
|
gap: 12px;
|
|
margin-bottom: 20px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.search-box {
|
|
flex: 1;
|
|
min-width: 200px;
|
|
padding: 10px 16px;
|
|
background: rgba(255,255,255,0.05);
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 8px;
|
|
color: white;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.search-box::placeholder { color: #6b7280; }
|
|
|
|
.filter-select {
|
|
padding: 10px 16px;
|
|
background: rgba(255,255,255,0.05);
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 8px;
|
|
color: white;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.filter-select option { background: #1a1a3e; }
|
|
|
|
.image-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
gap: 16px;
|
|
}
|
|
|
|
.image-card {
|
|
background: rgba(255,255,255,0.05);
|
|
backdrop-filter: blur(10px);
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.image-card:hover {
|
|
transform: translateY(-4px);
|
|
box-shadow: 0 8px 25px rgba(139, 92, 246, 0.2);
|
|
}
|
|
|
|
.image-card .thumb {
|
|
width: 100%;
|
|
height: 150px;
|
|
background: rgba(0,0,0,0.3);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.image-card .thumb img {
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.image-card .thumb.no-image {
|
|
color: #6b7280;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.image-card .info {
|
|
padding: 12px;
|
|
}
|
|
|
|
.image-card .name {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #e0e0e0;
|
|
margin-bottom: 4px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.image-card .barcode {
|
|
font-size: 11px;
|
|
color: #9ca3af;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.image-card .status {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.status.success { background: rgba(16,185,129,0.2); color: #10b981; }
|
|
.status.failed { background: rgba(239,68,68,0.2); color: #ef4444; }
|
|
.status.pending { background: rgba(245,158,11,0.2); color: #f59e0b; }
|
|
.status.no_result { background: rgba(107,114,128,0.2); color: #9ca3af; }
|
|
.status.manual { background: rgba(59,130,246,0.2); color: #3b82f6; }
|
|
|
|
.image-card .actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.image-card .btn-sm {
|
|
padding: 4px 8px;
|
|
font-size: 11px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0,0,0,0.8);
|
|
z-index: 1000;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.modal.show { display: flex; }
|
|
|
|
.modal-content {
|
|
background: #1a1a3e;
|
|
border-radius: 16px;
|
|
padding: 24px;
|
|
max-width: 500px;
|
|
width: 90%;
|
|
max-height: 80vh;
|
|
overflow: auto;
|
|
}
|
|
|
|
.modal-content h3 {
|
|
margin-bottom: 16px;
|
|
color: #a855f7;
|
|
}
|
|
|
|
.modal-content img {
|
|
max-width: 100%;
|
|
border-radius: 8px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #9ca3af;
|
|
}
|
|
|
|
.loading::after {
|
|
content: '';
|
|
display: inline-block;
|
|
width: 20px;
|
|
height: 20px;
|
|
border: 2px solid #a855f7;
|
|
border-top-color: transparent;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin-left: 10px;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
padding: 12px 24px;
|
|
border-radius: 8px;
|
|
color: white;
|
|
font-weight: 500;
|
|
z-index: 2000;
|
|
animation: slideIn 0.3s ease;
|
|
}
|
|
|
|
.toast.success { background: linear-gradient(135deg, #10b981, #059669); }
|
|
.toast.error { background: linear-gradient(135deg, #ef4444, #dc2626); }
|
|
.toast.info { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
|
|
|
@keyframes slideIn {
|
|
from { transform: translateX(100%); opacity: 0; }
|
|
to { transform: translateX(0); opacity: 1; }
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: #6b7280;
|
|
}
|
|
|
|
.empty-state .icon {
|
|
font-size: 48px;
|
|
margin-bottom: 16px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1>🖼️ 제품 이미지 관리</h1>
|
|
<div class="actions">
|
|
<button class="btn btn-primary" onclick="crawlToday()">
|
|
🔄 오늘 판매 제품 크롤링
|
|
</button>
|
|
<a href="/admin" class="btn btn-secondary">← 어드민</a>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="stats-bar" id="statsBar">
|
|
<div class="stat-card">
|
|
<div class="value" id="statTotal">-</div>
|
|
<div class="label">전체</div>
|
|
</div>
|
|
<div class="stat-card success">
|
|
<div class="value" id="statSuccess">-</div>
|
|
<div class="label">성공</div>
|
|
</div>
|
|
<div class="stat-card failed">
|
|
<div class="value" id="statFailed">-</div>
|
|
<div class="label">실패</div>
|
|
</div>
|
|
<div class="stat-card pending">
|
|
<div class="value" id="statPending">-</div>
|
|
<div class="label">대기</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="filters">
|
|
<input type="text" class="search-box" id="searchBox"
|
|
placeholder="제품명 또는 바코드 검색..."
|
|
onkeyup="debounceSearch()">
|
|
<select class="filter-select" id="statusFilter" onchange="loadImages()">
|
|
<option value="">전체 상태</option>
|
|
<option value="success">성공</option>
|
|
<option value="failed">실패</option>
|
|
<option value="no_result">검색결과없음</option>
|
|
<option value="pending">대기</option>
|
|
<option value="manual">수동등록</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="image-grid" id="imageGrid">
|
|
<div class="loading">이미지 로딩 중...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 상세 모달 -->
|
|
<div class="modal" id="detailModal">
|
|
<div class="modal-content">
|
|
<h3 id="modalTitle">제품 상세</h3>
|
|
<img id="modalImage" src="" alt="">
|
|
<div id="modalInfo"></div>
|
|
<div style="margin-top: 16px; text-align: right;">
|
|
<button class="btn btn-secondary" onclick="closeModal()">닫기</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let debounceTimer;
|
|
|
|
// 초기 로드
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadStats();
|
|
loadImages();
|
|
});
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const res = await fetch('/api/admin/product-images/stats');
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
document.getElementById('statTotal').textContent = data.total || 0;
|
|
document.getElementById('statSuccess').textContent = data.stats.success || 0;
|
|
document.getElementById('statFailed').textContent =
|
|
(data.stats.failed || 0) + (data.stats.no_result || 0);
|
|
document.getElementById('statPending').textContent = data.stats.pending || 0;
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
async function loadImages() {
|
|
const grid = document.getElementById('imageGrid');
|
|
const search = document.getElementById('searchBox').value;
|
|
const status = document.getElementById('statusFilter').value;
|
|
|
|
grid.innerHTML = '<div class="loading">이미지 로딩 중...</div>';
|
|
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (search) params.append('search', search);
|
|
if (status) params.append('status', status);
|
|
params.append('limit', 100);
|
|
|
|
const res = await fetch(`/api/admin/product-images?${params}`);
|
|
const data = await res.json();
|
|
|
|
if (data.success && data.items.length > 0) {
|
|
grid.innerHTML = data.items.map(item => `
|
|
<div class="image-card" data-barcode="${item.barcode}">
|
|
<div class="thumb ${item.thumbnail_base64 ? '' : 'no-image'}">
|
|
${item.thumbnail_base64
|
|
? `<img src="data:image/jpeg;base64,${item.thumbnail_base64}" alt="${item.product_name}">`
|
|
: '이미지 없음'}
|
|
</div>
|
|
<div class="info">
|
|
<div class="name" title="${item.product_name}">${item.product_name}</div>
|
|
<div class="barcode">${item.barcode}</div>
|
|
<span class="status ${item.status}">${getStatusText(item.status)}</span>
|
|
<div class="actions">
|
|
<button class="btn btn-secondary btn-sm" onclick="viewDetail('${item.barcode}')">상세</button>
|
|
<button class="btn btn-danger btn-sm" onclick="deleteImage('${item.barcode}')">삭제</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} else {
|
|
grid.innerHTML = `
|
|
<div class="empty-state" style="grid-column: 1/-1;">
|
|
<div class="icon">📷</div>
|
|
<div>등록된 이미지가 없습니다</div>
|
|
<div style="margin-top: 8px; font-size: 13px;">
|
|
"오늘 판매 제품 크롤링" 버튼을 눌러 시작하세요
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
} catch (err) {
|
|
grid.innerHTML = `<div class="empty-state" style="grid-column: 1/-1;">오류: ${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function getStatusText(status) {
|
|
const map = {
|
|
'success': '성공',
|
|
'failed': '실패',
|
|
'pending': '대기',
|
|
'no_result': '검색없음',
|
|
'manual': '수동등록'
|
|
};
|
|
return map[status] || status;
|
|
}
|
|
|
|
function debounceSearch() {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(loadImages, 300);
|
|
}
|
|
|
|
async function crawlToday() {
|
|
if (!confirm('오늘 판매된 제품 이미지를 크롤링합니다. 진행할까요?')) return;
|
|
|
|
showToast('크롤링 시작... 잠시 기다려주세요', 'info');
|
|
|
|
try {
|
|
const res = await fetch('/api/admin/product-images/crawl-today', {
|
|
method: 'POST'
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
const r = data.result;
|
|
showToast(`완료! 성공: ${r.success}, 실패: ${r.failed}, 스킵: ${r.skipped}`, 'success');
|
|
loadStats();
|
|
loadImages();
|
|
} else {
|
|
showToast(data.error || '크롤링 실패', 'error');
|
|
}
|
|
} catch (err) {
|
|
showToast('오류: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function viewDetail(barcode) {
|
|
try {
|
|
const res = await fetch(`/api/admin/product-images/${barcode}`);
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
const img = data.image;
|
|
document.getElementById('modalTitle').textContent = img.product_name;
|
|
document.getElementById('modalImage').src = img.image_base64
|
|
? `data:image/jpeg;base64,${img.image_base64}`
|
|
: '';
|
|
document.getElementById('modalImage').style.display = img.image_base64 ? 'block' : 'none';
|
|
document.getElementById('modalInfo').innerHTML = `
|
|
<p><strong>바코드:</strong> ${img.barcode}</p>
|
|
<p><strong>DrugCode:</strong> ${img.drug_code || '-'}</p>
|
|
<p><strong>검색어:</strong> ${img.search_name || '-'}</p>
|
|
<p><strong>상태:</strong> ${getStatusText(img.status)}</p>
|
|
<p><strong>원본 URL:</strong> <a href="${img.image_url}" target="_blank" style="color:#a855f7;">${img.image_url || '-'}</a></p>
|
|
<p><strong>등록일:</strong> ${img.created_at}</p>
|
|
${img.error_message ? `<p><strong>에러:</strong> ${img.error_message}</p>` : ''}
|
|
`;
|
|
document.getElementById('detailModal').classList.add('show');
|
|
}
|
|
} catch (err) {
|
|
showToast('상세 조회 실패', 'error');
|
|
}
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('detailModal').classList.remove('show');
|
|
}
|
|
|
|
async function deleteImage(barcode) {
|
|
if (!confirm('이 이미지를 삭제할까요?')) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/admin/product-images/${barcode}`, {
|
|
method: 'DELETE'
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
showToast('삭제 완료', 'success');
|
|
loadStats();
|
|
loadImages();
|
|
} else {
|
|
showToast(data.error || '삭제 실패', 'error');
|
|
}
|
|
} catch (err) {
|
|
showToast('오류: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(() => toast.remove(), 4000);
|
|
}
|
|
|
|
// ESC로 모달 닫기
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') closeModal();
|
|
});
|
|
|
|
// 모달 외부 클릭으로 닫기
|
|
document.getElementById('detailModal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'detailModal') closeModal();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|