pharmacy-pos-qr-system/backend/utils/yakkok_crawler.py
thug0bin 29648e3a7d feat: yakkok.com 제품 이미지 크롤러 + 어드민 페이지
크롤러 (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>
2026-03-02 23:19:52 +09:00

350 lines
13 KiB
Python

# -*- coding: utf-8 -*-
"""
yakkok.com 제품 이미지 크롤러
- 제품명으로 검색하여 이미지 URL 추출
- base64로 변환하여 SQLite에 저장
"""
import os
import sys
import sqlite3
import base64
import logging
import hashlib
import re
from datetime import datetime
from urllib.parse import quote
import requests
from PIL import Image
from io import BytesIO
# Playwright 동기 모드
from playwright.sync_api import sync_playwright
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
# DB 경로
DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'db', 'product_images.db')
# yakkok.com 설정
YAKKOK_BASE_URL = "https://yakkok.com"
YAKKOK_SEARCH_URL = "https://yakkok.com/search?q={query}"
def init_db():
"""DB 초기화"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# 스키마 파일 실행
schema_path = os.path.join(os.path.dirname(__file__), '..', 'db', 'product_images_schema.sql')
if os.path.exists(schema_path):
with open(schema_path, 'r', encoding='utf-8') as f:
cursor.executescript(f.read())
conn.commit()
conn.close()
logger.info(f"[DB] 초기화 완료: {DB_PATH}")
def get_existing_barcodes():
"""이미 저장된 바코드 목록 조회"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT barcode FROM product_images WHERE status IN ('success', 'manual')")
barcodes = set(row[0] for row in cursor.fetchall())
conn.close()
return barcodes
def save_product_image(barcode, drug_code, product_name, search_name,
image_base64, image_url, thumbnail_base64=None,
status='success', error_message=None):
"""제품 이미지 저장"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO product_images
(barcode, drug_code, product_name, search_name, image_base64, image_url,
thumbnail_base64, status, error_message, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (barcode, drug_code, product_name, search_name, image_base64, image_url,
thumbnail_base64, status, error_message, datetime.now().isoformat()))
conn.commit()
conn.close()
logger.info(f"[DB] 저장 완료: {product_name} ({barcode}) - {status}")
def download_image_as_base64(url, max_size=500):
"""이미지 다운로드 후 base64 변환 (리사이즈 포함)"""
try:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
# PIL로 이미지 열기
img = Image.open(BytesIO(response.content))
# RGBA -> RGB 변환 (JPEG 저장용)
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')
# 리사이즈 (비율 유지)
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)
base64_str = base64.b64encode(buffer.getvalue()).decode('utf-8')
return base64_str
except Exception as e:
logger.error(f"[ERROR] 이미지 다운로드 실패: {url} - {e}")
return None
def clean_product_name(name):
"""검색용 제품명 정리"""
# 괄호 안 내용 제거 (용량 등)
name = re.sub(r'\([^)]*\)', '', name)
# 숫자+단위 제거 (100ml, 500mg 등)
name = re.sub(r'\d+\s*(ml|mg|g|kg|정|캡슐|T|t|개|EA|ea)', '', name, flags=re.IGNORECASE)
# 특수문자 제거
name = re.sub(r'[_\-/\\]', ' ', name)
# 연속 공백 정리
name = re.sub(r'\s+', ' ', name).strip()
return name
def search_yakkok(page, product_name):
"""yakkok.com에서 제품 검색하여 이미지 URL 반환"""
try:
# 검색어 정리
search_name = clean_product_name(product_name)
if not search_name:
search_name = product_name
# 검색 페이지 접속
search_url = YAKKOK_SEARCH_URL.format(query=quote(search_name))
page.goto(search_url, wait_until='networkidle', timeout=15000)
# 잠시 대기
page.wait_for_timeout(1000)
# 첫 번째 검색 결과의 이미지 찾기
img_selector = 'img[alt]'
images = page.query_selector_all(img_selector)
for img in images:
src = img.get_attribute('src')
alt = img.get_attribute('alt') or ''
# 로고, 아이콘 등 제외
if not src or 'logo' in src.lower() or 'icon' in src.lower():
continue
# 검색 아이콘 등 제외
if alt in ['검색', '', '마이', '재고콕', '약콕인증', '뒤로가기']:
continue
# 제품 이미지로 보이는 것 반환
if src.startswith('http') or src.startswith('//'):
if src.startswith('//'):
src = 'https:' + src
return src, search_name
return None, search_name
except Exception as e:
logger.error(f"[ERROR] 검색 실패: {product_name} - {e}")
return None, search_name
def crawl_products(products, headless=True):
"""
제품 목록 크롤링
products: [(barcode, drug_code, product_name), ...]
"""
init_db()
existing = get_existing_barcodes()
# 새로 크롤링할 제품만 필터
to_crawl = [(b, d, n) for b, d, n in products if b not in existing]
if not to_crawl:
logger.info("[INFO] 크롤링할 새 제품이 없습니다.")
return {'total': 0, 'success': 0, 'failed': 0, 'skipped': len(products)}
logger.info(f"[INFO] 크롤링 시작: {len(to_crawl)}개 (스킵: {len(products) - len(to_crawl)}개)")
results = {'total': len(to_crawl), 'success': 0, 'failed': 0, 'skipped': len(products) - len(to_crawl)}
with sync_playwright() as p:
browser = p.chromium.launch(headless=headless)
context = browser.new_context(
viewport={'width': 390, 'height': 844}, # 모바일 뷰포트
user_agent='Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15'
)
page = context.new_page()
for barcode, drug_code, product_name in to_crawl:
try:
logger.info(f"[CRAWL] {product_name} ({barcode})")
# yakkok 검색
image_url, search_name = search_yakkok(page, product_name)
if image_url:
# 이미지 다운로드 & base64 변환
image_base64 = download_image_as_base64(image_url)
thumbnail_base64 = download_image_as_base64(image_url, max_size=100)
if image_base64:
save_product_image(
barcode=barcode,
drug_code=drug_code,
product_name=product_name,
search_name=search_name,
image_base64=image_base64,
image_url=image_url,
thumbnail_base64=thumbnail_base64,
status='success'
)
results['success'] += 1
else:
save_product_image(
barcode=barcode,
drug_code=drug_code,
product_name=product_name,
search_name=search_name,
image_base64=None,
image_url=image_url,
status='failed',
error_message='이미지 다운로드 실패'
)
results['failed'] += 1
else:
save_product_image(
barcode=barcode,
drug_code=drug_code,
product_name=product_name,
search_name=search_name,
image_base64=None,
image_url=None,
status='no_result',
error_message='검색 결과 없음'
)
results['failed'] += 1
# 요청 간 딜레이
page.wait_for_timeout(500)
except Exception as e:
logger.error(f"[ERROR] {product_name}: {e}")
save_product_image(
barcode=barcode,
drug_code=drug_code,
product_name=product_name,
search_name=product_name,
image_base64=None,
image_url=None,
status='failed',
error_message=str(e)
)
results['failed'] += 1
browser.close()
logger.info(f"[DONE] 완료 - 성공: {results['success']}, 실패: {results['failed']}, 스킵: {results['skipped']}")
return results
def get_today_sales_products():
"""오늘 판매된 제품 목록 조회 (MSSQL)"""
try:
# 상위 폴더의 db 모듈 import
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from db.dbsetup import db_manager
from sqlalchemy import text
session = db_manager.get_session('PM_PRES')
today = datetime.now().strftime('%Y%m%d')
# 오늘 판매된 품목 조회 (중복 제거)
query = text("""
SELECT DISTINCT
COALESCE(NULLIF(G.Barcode, ''),
(SELECT TOP 1 CD_CD_BARCODE FROM PM_DRUG.dbo.CD_ITEM_UNIT_MEMBER WHERE DrugCode = S.DrugCode)
) AS barcode,
S.DrugCode AS drug_code,
ISNULL(G.GoodsName, '알수없음') AS product_name
FROM SALE_SUB S
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
WHERE S.SL_NO_order LIKE :today_pattern
AND S.DrugCode IS NOT NULL
""")
result = session.execute(query, {'today_pattern': f'{today}%'}).fetchall()
products = []
for row in result:
barcode = row[0]
if barcode: # 바코드 있는 것만
products.append((barcode, row[1], row[2]))
logger.info(f"[MSSQL] 오늘 판매 품목: {len(products)}")
return products
except Exception as e:
logger.error(f"[ERROR] MSSQL 조회 실패: {e}")
return []
def crawl_today_sales(headless=True):
"""오늘 판매된 제품 이미지 크롤링"""
products = get_today_sales_products()
if not products:
return {'total': 0, 'success': 0, 'failed': 0, 'skipped': 0, 'message': '오늘 판매 내역 없음'}
return crawl_products(products, headless=headless)
# CLI 실행
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description='yakkok.com 제품 이미지 크롤러')
parser.add_argument('--today', action='store_true', help='오늘 판매 제품 크롤링')
parser.add_argument('--product', type=str, help='특정 제품명으로 테스트')
parser.add_argument('--visible', action='store_true', help='브라우저 표시')
args = parser.parse_args()
if args.today:
result = crawl_today_sales(headless=not args.visible)
print(f"\n결과: {result}")
elif args.product:
# 테스트용 단일 제품 크롤링
test_products = [('TEST001', 'TEST', args.product)]
result = crawl_products(test_products, headless=not args.visible)
print(f"\n결과: {result}")
else:
print("사용법:")
print(" python yakkok_crawler.py --today # 오늘 판매 제품 크롤링")
print(" python yakkok_crawler.py --product 타이레놀 # 특정 제품 테스트")
print(" python yakkok_crawler.py --visible # 브라우저 표시")