feat: 이미지 교체 기능 추가
- URL 입력으로 이미지 수동 교체 - 다양한 User-Agent로 다운로드 시도 (차단 우회) - base64 변환 + 썸네일 자동 생성 - status를 'manual'로 표시
This commit is contained in:
parent
ee28f97c11
commit
4a06e60e29
109
backend/app.py
109
backend/app.py
@ -5782,6 +5782,115 @@ def api_delete_product_image(barcode):
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/admin/product-images/<barcode>/replace', methods=['POST'])
|
||||||
|
def api_replace_product_image(barcode):
|
||||||
|
"""이미지 URL로 교체"""
|
||||||
|
import sqlite3
|
||||||
|
import requests
|
||||||
|
import base64
|
||||||
|
from PIL import Image
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
image_url = data.get('image_url', '').strip()
|
||||||
|
|
||||||
|
if not image_url:
|
||||||
|
return jsonify({'success': False, 'error': 'URL 필요'}), 400
|
||||||
|
|
||||||
|
# 다양한 User-Agent와 헤더로 시도
|
||||||
|
headers_list = [
|
||||||
|
{
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||||
|
'Referer': 'https://www.google.com/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||||
|
'Accept': 'image/*,*/*;q=0.8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'User-Agent': 'Googlebot-Image/1.0',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
response = None
|
||||||
|
for headers in headers_list:
|
||||||
|
try:
|
||||||
|
response = requests.get(image_url, headers=headers, timeout=15, allow_redirects=True)
|
||||||
|
if response.status_code == 200 and len(response.content) > 1000:
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not response or response.status_code != 200:
|
||||||
|
return jsonify({'success': False, 'error': f'이미지 다운로드 실패 (상태: {response.status_code if response else "연결실패"})'}), 400
|
||||||
|
|
||||||
|
# PIL로 이미지 처리
|
||||||
|
try:
|
||||||
|
img = Image.open(BytesIO(response.content))
|
||||||
|
|
||||||
|
# RGBA -> RGB 변환
|
||||||
|
if img.mode == 'RGBA':
|
||||||
|
bg = Image.new('RGB', img.size, (255, 255, 255))
|
||||||
|
bg.paste(img, mask=img.split()[3])
|
||||||
|
img = bg
|
||||||
|
elif img.mode != 'RGB':
|
||||||
|
img = img.convert('RGB')
|
||||||
|
|
||||||
|
# 리사이즈 (최대 500px)
|
||||||
|
max_size = 500
|
||||||
|
if max(img.size) > max_size:
|
||||||
|
ratio = max_size / max(img.size)
|
||||||
|
new_size = tuple(int(dim * ratio) for dim in img.size)
|
||||||
|
img = img.resize(new_size, Image.LANCZOS)
|
||||||
|
|
||||||
|
# base64 변환
|
||||||
|
buffer = BytesIO()
|
||||||
|
img.save(buffer, format='JPEG', quality=85)
|
||||||
|
image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||||
|
|
||||||
|
# 썸네일 생성
|
||||||
|
thumb_size = 100
|
||||||
|
ratio = thumb_size / max(img.size)
|
||||||
|
thumb_img = img.resize(tuple(int(dim * ratio) for dim in img.size), Image.LANCZOS)
|
||||||
|
thumb_buffer = BytesIO()
|
||||||
|
thumb_img.save(thumb_buffer, format='JPEG', quality=80)
|
||||||
|
thumbnail_base64 = base64.b64encode(thumb_buffer.getvalue()).decode('utf-8')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': f'이미지 처리 실패: {str(e)}'}), 400
|
||||||
|
|
||||||
|
# SQLite 업데이트
|
||||||
|
db_path = os.path.join(os.path.dirname(__file__), 'db', 'product_images.db')
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE product_images
|
||||||
|
SET image_base64 = ?, thumbnail_base64 = ?, image_url = ?,
|
||||||
|
status = 'manual', error_message = NULL, updated_at = datetime('now')
|
||||||
|
WHERE barcode = ?
|
||||||
|
""", (image_base64, thumbnail_base64, image_url, barcode))
|
||||||
|
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
# 레코드가 없으면 새로 생성
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO product_images (barcode, image_base64, thumbnail_base64, image_url, status, product_name)
|
||||||
|
VALUES (?, ?, ?, ?, 'manual', ?)
|
||||||
|
""", (barcode, image_base64, thumbnail_base64, image_url, barcode))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': '이미지 교체 완료'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"이미지 교체 오류: {e}")
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/admin/product-images/stats')
|
@app.route('/api/admin/product-images/stats')
|
||||||
def api_product_images_stats():
|
def api_product_images_stats():
|
||||||
"""이미지 통계"""
|
"""이미지 통계"""
|
||||||
|
|||||||
@ -410,6 +410,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 이미지 교체 모달 -->
|
||||||
|
<div class="modal" id="replaceModal">
|
||||||
|
<div class="modal-content" style="max-width: 500px;">
|
||||||
|
<h3>🔄 이미지 교체</h3>
|
||||||
|
<p style="color: #9ca3af; margin-bottom: 8px; font-size: 13px;">
|
||||||
|
구글 이미지 등에서 찾은 URL을 붙여넣으세요
|
||||||
|
</p>
|
||||||
|
<div id="replaceProductInfo" style="background: rgba(139,92,246,0.1); border-radius: 8px; padding: 12px; margin-bottom: 16px;">
|
||||||
|
<div style="font-weight: 600;" id="replaceProductName"></div>
|
||||||
|
<div style="font-size: 12px; color: #a855f7; font-family: monospace;" id="replaceBarcode"></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom: 16px;">
|
||||||
|
<label style="display: block; margin-bottom: 4px; font-size: 13px;">이미지 URL *</label>
|
||||||
|
<input type="text" id="replaceImageUrl" class="search-box" style="width: 100%;"
|
||||||
|
placeholder="https://example.com/image.jpg">
|
||||||
|
<div style="font-size: 11px; color: #6b7280; margin-top: 4px;">
|
||||||
|
💡 이미지 우클릭 → "이미지 주소 복사"로 URL을 가져오세요
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<button class="btn btn-secondary" onclick="closeReplaceModal()">취소</button>
|
||||||
|
<button class="btn btn-primary" onclick="submitReplace()">교체하기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 상세 모달 -->
|
<!-- 상세 모달 -->
|
||||||
<div class="modal" id="detailModal">
|
<div class="modal" id="detailModal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@ -478,6 +504,7 @@
|
|||||||
<span class="status ${item.status}">${getStatusText(item.status)}</span>
|
<span class="status ${item.status}">${getStatusText(item.status)}</span>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="viewDetail('${item.barcode}')">상세</button>
|
<button class="btn btn-secondary btn-sm" onclick="viewDetail('${item.barcode}')">상세</button>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openReplaceModal('${item.barcode}', '${escapeHtml(item.product_name)}')">교체</button>
|
||||||
<button class="btn btn-danger btn-sm" onclick="deleteImage('${item.barcode}')">삭제</button>
|
<button class="btn btn-danger btn-sm" onclick="deleteImage('${item.barcode}')">삭제</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -712,11 +739,65 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 이미지 교체
|
||||||
|
let replaceTargetBarcode = null;
|
||||||
|
|
||||||
|
function openReplaceModal(barcode, productName) {
|
||||||
|
replaceTargetBarcode = barcode;
|
||||||
|
document.getElementById('replaceProductName').textContent = productName || barcode;
|
||||||
|
document.getElementById('replaceBarcode').textContent = barcode;
|
||||||
|
document.getElementById('replaceImageUrl').value = '';
|
||||||
|
document.getElementById('replaceModal').classList.add('show');
|
||||||
|
document.getElementById('replaceImageUrl').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReplaceModal() {
|
||||||
|
document.getElementById('replaceModal').classList.remove('show');
|
||||||
|
replaceTargetBarcode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReplace() {
|
||||||
|
const imageUrl = document.getElementById('replaceImageUrl').value.trim();
|
||||||
|
|
||||||
|
if (!imageUrl) {
|
||||||
|
showToast('이미지 URL을 입력하세요', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageUrl.startsWith('http')) {
|
||||||
|
showToast('올바른 URL을 입력하세요 (http:// 또는 https://)', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeReplaceModal();
|
||||||
|
showToast('이미지 다운로드 중...', 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/product-images/${replaceTargetBarcode}/replace`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ image_url: imageUrl })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showToast('✅ 이미지 교체 완료!', 'success');
|
||||||
|
loadStats();
|
||||||
|
loadImages();
|
||||||
|
} else {
|
||||||
|
showToast(data.error || '교체 실패', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('오류: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ESC로 모달 닫기
|
// ESC로 모달 닫기
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
closeModal();
|
closeModal();
|
||||||
closeManualCrawl();
|
closeManualCrawl();
|
||||||
|
closeReplaceModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -728,6 +809,10 @@
|
|||||||
document.getElementById('manualCrawlModal').addEventListener('click', (e) => {
|
document.getElementById('manualCrawlModal').addEventListener('click', (e) => {
|
||||||
if (e.target.id === 'manualCrawlModal') closeManualCrawl();
|
if (e.target.id === 'manualCrawlModal') closeManualCrawl();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('replaceModal').addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'replaceModal') closeReplaceModal();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user