Initial commit: AutonetSellCar platform with deployment system

- Frontend: Next.js 14 with TypeScript
- Backend: FastAPI with SQLAlchemy
- Agent: Carmodoo sync agent
- Deployment: Docker Compose based staging/production setup
- Scripts: Automated deployment with rollback support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
AutonetSellCar Deploy
2025-12-30 13:24:39 +09:00
commit 1f0dcb1ddb
224 changed files with 55119 additions and 0 deletions

View File

@@ -0,0 +1,310 @@
"""
캐시 서비스 - 카모두 검색 결과 캐싱 및 필터링
"""
import asyncio
import json
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any, Tuple, TYPE_CHECKING
from sqlalchemy.orm import Session
from sqlalchemy import and_
from ..models.cache import CarCache, CarDetailCache, CacheRequestQueue
if TYPE_CHECKING:
from ..api.carmodoo import CarmodooClient
# 캐시 TTL 설정 (시간 단위)
CACHE_TTL_HOURS = 2
# 요청 큐 락
_request_lock = asyncio.Lock()
_pending_requests: Dict[str, asyncio.Event] = {}
class CacheService:
def __init__(self, db: Session, carmodoo_client: "CarmodooClient" = None):
self.db = db
self.carmodoo_client = carmodoo_client
def get_cache_key(self, maker_code: str, model_code: str) -> str:
"""캐시 키 생성"""
return f"{maker_code}_{model_code}"
def get_cache(self, cache_key: str) -> Optional[CarCache]:
"""캐시 조회 (만료 확인)"""
cache = self.db.query(CarCache).filter(
CarCache.cache_key == cache_key
).first()
if cache:
# 만료 확인
if cache.expires_at < datetime.utcnow():
# 만료된 캐시 삭제
self.db.delete(cache)
self.db.commit()
return None
return cache
return None
def save_cache(
self,
cache_key: str,
maker_code: str,
maker_name: str,
model_code: str,
model_name: str,
cars: List[Dict[str, Any]]
) -> CarCache:
"""캐시 저장"""
expires_at = datetime.utcnow() + timedelta(hours=CACHE_TTL_HOURS)
# 기존 캐시 삭제
existing = self.db.query(CarCache).filter(
CarCache.cache_key == cache_key
).first()
if existing:
self.db.delete(existing)
self.db.commit()
# 새 캐시 저장
cache = CarCache(
cache_key=cache_key,
maker_code=maker_code,
maker_name=maker_name,
model_code=model_code,
model_name=model_name,
total_count=len(cars),
cars_data=json.dumps(cars, ensure_ascii=False),
expires_at=expires_at
)
self.db.add(cache)
self.db.commit()
self.db.refresh(cache)
return cache
def get_cars_from_cache(self, cache: CarCache) -> List[Dict[str, Any]]:
"""캐시에서 차량 목록 가져오기"""
return json.loads(cache.cars_data)
def filter_cars(
self,
cars: List[Dict[str, Any]],
year_min: Optional[int] = None,
year_max: Optional[int] = None,
mileage_min: Optional[int] = None,
mileage_max: Optional[int] = None,
price_min: Optional[int] = None,
price_max: Optional[int] = None,
fuel: Optional[str] = None,
transmission: Optional[str] = None,
displacement_min: Optional[int] = None,
displacement_max: Optional[int] = None
) -> List[Dict[str, Any]]:
"""캐시된 데이터에서 필터링"""
filtered = cars
if year_min:
filtered = [c for c in filtered if c.get('year') and c['year'] >= year_min]
if year_max:
filtered = [c for c in filtered if c.get('year') and c['year'] <= year_max]
if mileage_min:
filtered = [c for c in filtered if c.get('mileage') and c['mileage'] >= mileage_min]
if mileage_max:
filtered = [c for c in filtered if c.get('mileage') and c['mileage'] <= mileage_max]
if price_min:
# 'price' 또는 'original_price' 키 둘 다 체크 (카모두 파싱 결과는 'price', 변환 후에는 'original_price')
filtered = [c for c in filtered if (c.get('price') or c.get('original_price')) and (c.get('price') or c.get('original_price', 0)) >= price_min]
if price_max:
filtered = [c for c in filtered if (c.get('price') or c.get('original_price')) and (c.get('price') or c.get('original_price', 0)) <= price_max]
if fuel:
# 연료 타입 매핑 (프론트엔드 값 -> 카모두 값)
fuel_map = {
'가솔린': ['휘발유', '가솔린'],
'디젤': ['경유', '디젤'],
'LPG': ['LPG'],
'하이브리드': ['하이브리드'],
'전기': ['전기'],
'휘발유': ['휘발유', '가솔린'],
'경유': ['경유', '디젤'],
}
allowed_fuels = fuel_map.get(fuel, [fuel])
filtered = [c for c in filtered if c.get('fuel') in allowed_fuels]
if transmission:
# 변속기 타입 매핑
trans_map = {
'자동': ['오토', '자동'],
'수동': ['수동'],
'세미오토': ['세미오토'],
'CVT': ['CVT'],
}
allowed_trans = trans_map.get(transmission, [transmission])
filtered = [c for c in filtered if c.get('transmission') in allowed_trans]
if displacement_min:
filtered = [c for c in filtered if c.get('displacement') and c['displacement'] >= displacement_min]
if displacement_max:
filtered = [c for c in filtered if c.get('displacement') and c['displacement'] <= displacement_max]
return filtered
def paginate_cars(
self,
cars: List[Dict[str, Any]],
page: int = 1,
page_size: int = 20
) -> Tuple[List[Dict[str, Any]], int]:
"""페이징 처리"""
total = len(cars)
start = (page - 1) * page_size
end = start + page_size
return cars[start:end], total
async def fetch_all_cars_for_cache(
self,
maker_code: str,
model_code: str,
maker_name: str = "",
model_name: str = ""
) -> List[Dict[str, Any]]:
"""캐시용 전체 데이터 수집 (연도별 분할 검색)
카모두 API는 페이징이 제대로 동작하지 않아 한 번에 최대 50대만 반환합니다.
연도별로 나누어 검색하여 더 많은 차량을 수집합니다.
"""
if not self.carmodoo_client:
return []
try:
# 연도별 분할 검색 사용 (최근 15년간)
all_cars = await self.carmodoo_client.search_cars_by_year_segment(
maker_code=maker_code,
model_code=model_code,
year_start=2010, # 2010년부터
year_end=None # 현재 연도까지
)
return all_cars
except Exception as e:
print(f"Error fetching cars for cache: {e}")
return []
async def get_or_fetch_cache(
self,
maker_code: str,
model_code: str,
maker_name: str = "",
model_name: str = ""
) -> Optional[CarCache]:
"""캐시 조회 또는 새로 가져오기 (요청 병합 포함)"""
cache_key = self.get_cache_key(maker_code, model_code)
# 1. 캐시 확인
cache = self.get_cache(cache_key)
if cache:
return cache
# 2. 요청 락으로 동시 요청 병합
async with _request_lock:
# 다른 요청이 이미 처리 중인지 확인
if cache_key in _pending_requests:
event = _pending_requests[cache_key]
else:
# 새 이벤트 생성
event = asyncio.Event()
_pending_requests[cache_key] = event
# 백그라운드에서 데이터 가져오기
asyncio.create_task(
self._fetch_and_cache(cache_key, maker_code, model_code, maker_name, model_name, event)
)
# 3. 완료 대기
await event.wait()
# 4. 캐시 반환
return self.get_cache(cache_key)
async def _fetch_and_cache(
self,
cache_key: str,
maker_code: str,
model_code: str,
maker_name: str,
model_name: str,
event: asyncio.Event
):
"""데이터 가져와서 캐시에 저장"""
try:
cars = await self.fetch_all_cars_for_cache(
maker_code, model_code, maker_name, model_name
)
if cars:
self.save_cache(
cache_key=cache_key,
maker_code=maker_code,
maker_name=maker_name,
model_code=model_code,
model_name=model_name,
cars=cars
)
except Exception as e:
print(f"Error caching {cache_key}: {e}")
finally:
# 완료 시그널
event.set()
# 대기열에서 제거
if cache_key in _pending_requests:
del _pending_requests[cache_key]
def cleanup_expired_cache(self):
"""만료된 캐시 정리"""
expired = self.db.query(CarCache).filter(
CarCache.expires_at < datetime.utcnow()
).all()
for cache in expired:
self.db.delete(cache)
self.db.commit()
return len(expired)
# 상세 정보 캐시 관련
def get_detail_cache(self, car_id: str) -> Optional[CarDetailCache]:
"""상세 정보 캐시 조회"""
cache = self.db.query(CarDetailCache).filter(
CarDetailCache.car_id == car_id
).first()
if cache:
if cache.expires_at < datetime.utcnow():
self.db.delete(cache)
self.db.commit()
return None
return cache
return None
def save_detail_cache(self, car_id: str, detail_data: Dict[str, Any]) -> CarDetailCache:
"""상세 정보 캐시 저장"""
expires_at = datetime.utcnow() + timedelta(hours=CACHE_TTL_HOURS)
existing = self.db.query(CarDetailCache).filter(
CarDetailCache.car_id == car_id
).first()
if existing:
self.db.delete(existing)
self.db.commit()
cache = CarDetailCache(
car_id=car_id,
detail_data=json.dumps(detail_data, ensure_ascii=False),
expires_at=expires_at
)
self.db.add(cache)
self.db.commit()
self.db.refresh(cache)
return cache
def get_detail_from_cache(self, cache: CarDetailCache) -> Dict[str, Any]:
"""상세 정보 캐시에서 데이터 가져오기"""
return json.loads(cache.detail_data)

View File

@@ -0,0 +1,305 @@
"""
Exchange Rate Service - 한국수출입은행 API 연동
API 문서: https://www.koreaexim.go.kr/ir/HPHKIR020M01?apino=2&viewtype=C
"""
import httpx
import os
from datetime import datetime, timedelta
from typing import Optional, Dict, List
from sqlalchemy.orm import Session
from ..models.exchange_rate import ExchangeRate, ExchangeRateHistory
# 한국수출입은행 API 설정
KOREAEXIM_API_URL = "https://oapi.koreaexim.go.kr/site/program/financial/exchangeJSON"
KOREAEXIM_API_KEY = os.getenv("KOREAEXIM_API_KEY", "rOzKaATDEinF9luHla1wVTosjWribjKL")
# 지원 통화 목록
SUPPORTED_CURRENCIES = {
"USD": {"name_ko": "미국 달러", "name_en": "US Dollar", "symbol": "$"},
"MNT": {"name_ko": "몽골 투그릭", "name_en": "Mongolian Tugrik", "symbol": ""},
"RUB": {"name_ko": "러시아 루블", "name_en": "Russian Ruble", "symbol": ""},
"CNY": {"name_ko": "중국 위안", "name_en": "Chinese Yuan", "symbol": "¥"},
"JPY": {"name_ko": "일본 엔", "name_en": "Japanese Yen", "symbol": "¥"},
"EUR": {"name_ko": "유로", "name_en": "Euro", "symbol": ""},
}
# 기본 환율 (API 실패 시 사용, 2024년 12월 기준)
DEFAULT_RATES = {
"USD": 1450.0,
"MNT": 0.42, # 1 MNT = 0.42 KRW
"RUB": 14.0,
"CNY": 198.0,
"JPY": 9.5, # 100엔 기준이면 950
"EUR": 1510.0,
}
async def fetch_rates_from_koreaexim(search_date: Optional[str] = None) -> Optional[List[Dict]]:
"""
한국수출입은행 API에서 환율 정보 조회
Args:
search_date: 조회일자 (YYYYMMDD 형식), 없으면 오늘
Returns:
환율 데이터 리스트 또는 None
"""
if not KOREAEXIM_API_KEY:
print("Warning: KOREAEXIM_API_KEY not set, using fallback rates")
return None
if not search_date:
search_date = datetime.now().strftime("%Y%m%d")
try:
async with httpx.AsyncClient() as client:
response = await client.get(
KOREAEXIM_API_URL,
params={
"authkey": KOREAEXIM_API_KEY,
"searchdate": search_date,
"data": "AP01" # 환율 데이터
},
timeout=15.0
)
if response.status_code == 200:
data = response.json()
# API 결과 코드 확인
if isinstance(data, list) and len(data) > 0:
return data
else:
print(f"Korea Exim API returned empty data for date {search_date}")
# 주말/공휴일이면 이전 영업일 데이터 조회
return None
except Exception as e:
print(f"Failed to fetch from Korea Exim API: {e}")
return None
def parse_koreaexim_response(data: List[Dict]) -> Dict[str, Dict]:
"""
한국수출입은행 API 응답 파싱
Response format:
{
"result": 1,
"cur_unit": "USD",
"cur_nm": "미국 달러",
"ttb": "1,438.71", # 전신환(송금) 받을때
"tts": "1,467.28", # 전신환(송금) 보낼때
"deal_bas_r": "1,452.99", # 매매 기준율
"bkpr": "1,452", # 장부가격
...
}
"""
parsed = {}
for item in data:
try:
cur_unit = item.get("cur_unit", "").replace("(100)", "").strip()
if cur_unit not in SUPPORTED_CURRENCIES:
continue
# 쉼표 제거 후 숫자 변환
deal_base_rate = float(item.get("deal_bas_r", "0").replace(",", ""))
ttb_rate = float(item.get("ttb", "0").replace(",", ""))
tts_rate = float(item.get("tts", "0").replace(",", ""))
# 100엔 단위인 경우 (JPY(100))
if "(100)" in item.get("cur_unit", ""):
deal_base_rate /= 100
ttb_rate /= 100
tts_rate /= 100
parsed[cur_unit] = {
"currency_code": cur_unit,
"currency_name": item.get("cur_nm", SUPPORTED_CURRENCIES[cur_unit]["name_ko"]),
"deal_base_rate": deal_base_rate,
"ttb_rate": ttb_rate,
"tts_rate": tts_rate,
}
except (ValueError, KeyError) as e:
print(f"Error parsing currency {item.get('cur_unit')}: {e}")
continue
return parsed
async def update_exchange_rates(db: Session, force: bool = False) -> Dict:
"""
환율 정보 업데이트
Args:
db: DB 세션
force: 강제 업데이트 여부
Returns:
업데이트 결과
"""
today = datetime.now().strftime("%Y%m%d")
# 오늘 이미 업데이트했는지 확인 (force가 아닌 경우)
if not force:
existing = db.query(ExchangeRate).filter(
ExchangeRate.source_date == today
).first()
if existing:
return {
"status": "skipped",
"message": f"Already updated for {today}",
"source_date": today
}
# API 호출 (오늘 데이터 시도)
api_data = await fetch_rates_from_koreaexim(today)
source_date = today
# 오늘 데이터 없으면 어제 시도 (주말/공휴일 대응)
if not api_data:
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
api_data = await fetch_rates_from_koreaexim(yesterday)
source_date = yesterday
# 그래도 없으면 기본값 사용
if not api_data:
print("Using fallback rates")
rates_data = {
code: {
"currency_code": code,
"currency_name": info["name_ko"],
"deal_base_rate": DEFAULT_RATES.get(code, 1.0),
"ttb_rate": DEFAULT_RATES.get(code, 1.0) * 0.98,
"tts_rate": DEFAULT_RATES.get(code, 1.0) * 1.02,
}
for code, info in SUPPORTED_CURRENCIES.items()
}
source = "fallback"
else:
rates_data = parse_koreaexim_response(api_data)
source = "koreaexim"
# DB에 저장/업데이트
updated_currencies = []
for code, rate_info in rates_data.items():
existing = db.query(ExchangeRate).filter(
ExchangeRate.currency_code == code
).first()
if existing:
# 기존 데이터 업데이트
old_rate = existing.deal_base_rate
existing.currency_name = rate_info["currency_name"]
existing.deal_base_rate = rate_info["deal_base_rate"]
existing.ttb_rate = rate_info["ttb_rate"]
existing.tts_rate = rate_info["tts_rate"]
existing.adjusted_rate = rate_info["deal_base_rate"] * (1 + existing.weight_percent / 100)
existing.source_date = source_date
# 변동이 있으면 히스토리 저장
if old_rate != rate_info["deal_base_rate"]:
history = ExchangeRateHistory(
currency_code=code,
deal_base_rate=rate_info["deal_base_rate"],
source_date=source_date
)
db.add(history)
else:
# 신규 데이터 추가
new_rate = ExchangeRate(
currency_code=code,
currency_name=rate_info["currency_name"],
deal_base_rate=rate_info["deal_base_rate"],
ttb_rate=rate_info["ttb_rate"],
tts_rate=rate_info["tts_rate"],
weight_percent=0.0,
adjusted_rate=rate_info["deal_base_rate"],
source_date=source_date,
is_active=True
)
db.add(new_rate)
# 히스토리 저장
history = ExchangeRateHistory(
currency_code=code,
deal_base_rate=rate_info["deal_base_rate"],
source_date=source_date
)
db.add(history)
updated_currencies.append(code)
db.commit()
return {
"status": "success",
"message": f"Updated {len(updated_currencies)} currencies",
"currencies": updated_currencies,
"source": source,
"source_date": source_date
}
def get_exchange_rate(db: Session, currency_code: str) -> Optional[ExchangeRate]:
"""특정 통화 환율 조회"""
return db.query(ExchangeRate).filter(
ExchangeRate.currency_code == currency_code,
ExchangeRate.is_active == True
).first()
def get_all_exchange_rates(db: Session) -> List[ExchangeRate]:
"""모든 환율 조회"""
return db.query(ExchangeRate).filter(
ExchangeRate.is_active == True
).all()
def convert_krw_to_currency(db: Session, krw_amount: float, currency_code: str) -> Optional[float]:
"""
KRW를 다른 통화로 변환
Args:
db: DB 세션
krw_amount: 원화 금액
currency_code: 대상 통화 코드 (USD, MNT, RUB, CNY)
Returns:
변환된 금액 또는 None
"""
rate = get_exchange_rate(db, currency_code)
if not rate or rate.adjusted_rate <= 0:
return None
# KRW / 환율 = 외화
return krw_amount / rate.adjusted_rate
def convert_currency_to_krw(db: Session, amount: float, currency_code: str) -> Optional[float]:
"""
다른 통화를 KRW로 변환
Args:
db: DB 세션
amount: 외화 금액
currency_code: 원화 통화 코드
Returns:
KRW 금액 또는 None
"""
rate = get_exchange_rate(db, currency_code)
if not rate:
return None
# 외화 * 환율 = KRW
return amount * rate.adjusted_rate

View File

@@ -0,0 +1,356 @@
"""
PDF Service for capturing web pages as PDF using Playwright
Used for capturing Korean vehicle performance check reports (성능점검기록부)
"""
import os
import asyncio
import logging
from pathlib import Path
from typing import Optional, List, Tuple
from datetime import datetime
import tempfile
# Configure logging
logger = logging.getLogger(__name__)
# PDF generation failure log
PDF_FAILURES: List[dict] = [] # In-memory log of recent failures
# Playwright imports
try:
from playwright.async_api import async_playwright, Browser, Page
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
print("Warning: Playwright not installed. PDF capture will not work.")
# Image to PDF imports
try:
import img2pdf
from PIL import Image
IMG2PDF_AVAILABLE = True
except ImportError:
IMG2PDF_AVAILABLE = False
print("Warning: img2pdf/pillow not installed. Image-based PDF will not work.")
# PDF storage directory
PDF_STORAGE_DIR = Path(__file__).parent.parent.parent / "uploads" / "performance_checks"
def ensure_pdf_directory():
"""Ensure PDF storage directory exists"""
PDF_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
def log_pdf_failure(car_id: int, check_num: str, error: str):
"""Log PDF generation failure"""
global PDF_FAILURES
failure = {
"car_id": car_id,
"check_num": check_num,
"error": str(error),
"timestamp": datetime.now().isoformat(),
"retried": False
}
PDF_FAILURES.append(failure)
# Keep only last 100 failures
if len(PDF_FAILURES) > 100:
PDF_FAILURES = PDF_FAILURES[-100:]
logger.error(f"PDF generation failed - car_id={car_id}, check_num={check_num}: {error}")
def get_pdf_failures() -> List[dict]:
"""Get list of recent PDF generation failures"""
return PDF_FAILURES.copy()
def clear_pdf_failure(car_id: int):
"""Clear failure record for a car after successful retry"""
global PDF_FAILURES
PDF_FAILURES = [f for f in PDF_FAILURES if f["car_id"] != car_id]
async def capture_performance_check_pdf(
check_num: str,
car_id: int,
timeout: int = 60000,
max_retries: int = 3,
retry_delay: int = 2
) -> Optional[str]:
"""
Capture Korean vehicle performance check report as PDF
Uses screenshot-based approach for accurate rendering
Includes automatic retry on failure
Args:
check_num: Performance check number (성능점검번호)
car_id: Car ID for naming the PDF file
timeout: Page load timeout in milliseconds
max_retries: Maximum number of retry attempts (default: 3)
retry_delay: Delay between retries in seconds (default: 2)
Returns:
PDF file path (relative) if successful, None if failed
"""
if not PLAYWRIGHT_AVAILABLE:
error_msg = "Playwright not available. Cannot capture PDF."
logger.error(error_msg)
log_pdf_failure(car_id, check_num, error_msg)
return None
if not IMG2PDF_AVAILABLE:
error_msg = "img2pdf/pillow not available. Cannot create PDF from screenshots."
logger.error(error_msg)
log_pdf_failure(car_id, check_num, error_msg)
return None
ensure_pdf_directory()
last_error = None
for attempt in range(1, max_retries + 1):
# 별도 스레드에서 새 이벤트 루프로 실행하여 uvicorn과의 충돌 방지
try:
result = await asyncio.get_event_loop().run_in_executor(
None,
_capture_pdf_in_new_loop,
check_num, car_id, timeout, attempt
)
if result:
# Success - clear any previous failure record
clear_pdf_failure(car_id)
return result
except Exception as e:
logger.error(f"PDF capture attempt {attempt} failed: {e}")
if attempt < max_retries:
logger.warning(f"PDF capture attempt {attempt}/{max_retries} failed for car_id={car_id}, retrying in {retry_delay}s...")
await asyncio.sleep(retry_delay)
# All retries failed
log_pdf_failure(car_id, check_num, f"Failed after {max_retries} attempts")
return None
def _capture_pdf_in_new_loop(check_num: str, car_id: int, timeout: int, attempt: int) -> Optional[str]:
"""별도 이벤트 루프에서 PDF 캡처 실행"""
import asyncio
# 새 이벤트 루프 생성
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(_capture_pdf_single_attempt(check_num, car_id, timeout, attempt))
return result
finally:
loop.close()
async def _capture_pdf_single_attempt(
check_num: str,
car_id: int,
timeout: int,
attempt: int
) -> Optional[str]:
"""Single attempt to capture PDF"""
print(f"[PDF] _capture_pdf_single_attempt: car_id={car_id}, check_num={check_num}, attempt={attempt}")
ensure_pdf_directory()
# Performance check URL from carmodoo
url = f"https://ck.carmodoo.com/carCheck/carmodooPrint.do?print=0&checkNum={check_num}"
print(f"[PDF] URL: {url}")
# PDF filename: car_id_timestamp.pdf
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
pdf_filename = f"{car_id}_{timestamp}.pdf"
pdf_path = PDF_STORAGE_DIR / pdf_filename
relative_path = f"/uploads/performance_checks/{pdf_filename}"
print(f"[PDF] Output path: {pdf_path}")
temp_images: List[Path] = []
browser = None
try:
print(f"[PDF] Launching playwright...")
async with async_playwright() as p:
# Launch browser (headless mode) with extended timeout
print(f"[PDF] Launching chromium...")
browser: Browser = await p.chromium.launch(
headless=True,
timeout=30000, # 30 second browser launch timeout
args=[
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-extensions',
'--disable-background-networking',
'--single-process' # Use single process for stability
]
)
print(f"[PDF] Browser launched")
# Create new page - narrower viewport for larger content
context = await browser.new_context(
locale='ko-KR',
viewport={'width': 900, 'height': 800},
device_scale_factor=2 # High DPI for better quality
)
page: Page = await context.new_page()
print(f"[PDF] Page created, navigating to URL...")
# Navigate to performance check page
await page.goto(url, wait_until='networkidle', timeout=timeout)
print(f"[PDF] Navigation complete")
# Wait for content to fully load
await page.wait_for_timeout(3000)
print(f"[PDF] Content loaded, taking screenshot...")
# Get full page dimensions
page_height = await page.evaluate("document.documentElement.scrollHeight")
page_width = await page.evaluate("document.documentElement.scrollWidth")
print(f"Page size: {page_width}x{page_height}")
# Take single full-page screenshot (no page splits)
screenshot_path = PDF_STORAGE_DIR / f"temp_{car_id}_full.png"
await page.screenshot(
path=str(screenshot_path),
full_page=True
)
temp_images.append(screenshot_path)
print(f"Captured full page screenshot")
await browser.close()
# Convert screenshots to PDF
if temp_images:
print(f"Converting {len(temp_images)} images to PDF...")
# Process images for A4 size
processed_images = []
for img_path in temp_images:
# Open and convert to RGB (required for PDF)
with Image.open(img_path) as img:
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
# Save as temporary JPEG for better compression
temp_jpg = img_path.with_suffix('.jpg')
img.save(temp_jpg, 'JPEG', quality=95)
processed_images.append(temp_jpg)
# Create PDF with margins (25mm left/right, 30mm top/bottom)
margin_lr_mm = 25 # left/right margin
margin_tb_mm = 30 # top/bottom margin
# Get image dimensions to calculate page size
with Image.open(processed_images[0]) as img:
img_width_px, img_height_px = img.size
# Convert image pixels to points (assuming 150 DPI for reasonable size)
dpi = 150
img_width_pt = img_width_px * 72 / dpi
img_height_pt = img_height_px * 72 / dpi
# Page size = image size + margins
page_width_pt = img_width_pt + 2 * img2pdf.mm_to_pt(margin_lr_mm)
page_height_pt = img_height_pt + 2 * img2pdf.mm_to_pt(margin_tb_mm)
with open(pdf_path, 'wb') as f:
pdf_bytes = img2pdf.convert(
[str(img) for img in processed_images],
layout_fun=img2pdf.get_layout_fun(
pagesize=(page_width_pt, page_height_pt),
border=(img2pdf.mm_to_pt(margin_lr_mm), img2pdf.mm_to_pt(margin_tb_mm),
img2pdf.mm_to_pt(margin_lr_mm), img2pdf.mm_to_pt(margin_tb_mm)),
fit=img2pdf.FitMode.into
)
)
f.write(pdf_bytes)
# Cleanup temporary files
for img_path in temp_images:
if img_path.exists():
img_path.unlink()
for img_path in processed_images:
if img_path.exists():
img_path.unlink()
# Verify PDF was created
if pdf_path.exists() and pdf_path.stat().st_size > 0:
logger.info(f"PDF captured successfully (attempt {attempt}): {pdf_path}")
return relative_path
else:
logger.warning(f"PDF file not created or empty: {pdf_path}")
return None
except Exception as e:
import traceback
error_trace = traceback.format_exc()
logger.error(f"Error capturing PDF for check_num={check_num} (attempt {attempt}): {e}\n{error_trace}")
print(f"[PDF] ERROR: {e}\n{error_trace}")
# Cleanup on error
for img_path in temp_images:
if img_path.exists():
img_path.unlink()
return None
def capture_performance_check_pdf_sync(check_num: str, car_id: int) -> Optional[str]:
"""
Synchronous wrapper for capture_performance_check_pdf
For use in non-async contexts
"""
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop.run_until_complete(capture_performance_check_pdf(check_num, car_id))
def get_pdf_path(car_id: int) -> Optional[str]:
"""
Get existing PDF path for a car if it exists
Returns the most recent PDF for the car
"""
ensure_pdf_directory()
# Find all PDFs for this car
pattern = f"{car_id}_*.pdf"
pdf_files = list(PDF_STORAGE_DIR.glob(pattern))
if not pdf_files:
return None
# Return the most recent one
latest_pdf = max(pdf_files, key=lambda p: p.stat().st_mtime)
return f"/uploads/performance_checks/{latest_pdf.name}"
def delete_pdf(relative_path: str) -> bool:
"""Delete a PDF file"""
try:
filename = Path(relative_path).name
full_path = PDF_STORAGE_DIR / filename
if full_path.exists():
full_path.unlink()
return True
return False
except Exception as e:
print(f"Error deleting PDF: {e}")
return False
def get_pdf_full_path(relative_path: str) -> Optional[Path]:
"""Get full filesystem path from relative path"""
if not relative_path:
return None
filename = Path(relative_path).name
full_path = PDF_STORAGE_DIR / filename
if full_path.exists():
return full_path
return None

View File

@@ -0,0 +1,181 @@
"""
Sensitive Information Detection and Masking Service
Detects and masks Korean phone numbers, addresses, and other PII in dealer descriptions.
"""
import re
from typing import List, Tuple, Dict
# Korean phone number patterns
PHONE_PATTERNS = [
r'01[0-9]-?\d{3,4}-?\d{4}', # Mobile: 010-1234-5678, 0101234567
r'02-?\d{3,4}-?\d{4}', # Seoul: 02-123-4567
r'0[3-6][0-9]-?\d{3,4}-?\d{4}', # Regional: 031-123-4567
r'070-?\d{3,4}-?\d{4}', # Internet phone: 070-1234-5678
r'1[0-9]{2,3}-?\d{4}', # Service numbers: 1588-1234
r'\d{2,4}[-.)]\s*\d{3,4}[-.)]\s*\d{4}', # Various formats with separators
]
# Korean address patterns
ADDRESS_PATTERNS = [
r'[가-힣]+시\s+[가-힣]+구', # 서울시 강남구
r'[가-힣]+도\s+[가-힣]+시', # 경기도 성남시
r'[가-힣]+시\s+[가-힣]+동', # 서울시 역삼동
r'[가-힣]+구\s+[가-힣]+동', # 강남구 역삼동
r'[가-힣]+로\s*\d+', # 테헤란로 123
r'[가-힣]+길\s*\d+', # 역삼길 45
r'\d+번지', # 123번지
r'[가-힣]+빌딩', # XX빌딩
r'[가-힣]+타워', # XX타워
r'[가-힣]+센터', # XX센터
]
# Other sensitive patterns
OTHER_PATTERNS = [
r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', # Email
r'카[카톡|톡]\s*:?\s*[a-zA-Z0-9가-힣]+', # 카톡 ID
r'카카오톡?\s*:?\s*[a-zA-Z0-9가-힣]+', # 카카오톡 ID
]
def detect_sensitive_info(text: str) -> Dict[str, List[Tuple[int, int, str]]]:
"""
Detect sensitive information in text.
Returns:
Dict with categories as keys and list of (start, end, matched_text) tuples as values.
"""
if not text:
return {"phones": [], "addresses": [], "others": []}
result = {
"phones": [],
"addresses": [],
"others": []
}
# Detect phone numbers
for pattern in PHONE_PATTERNS:
for match in re.finditer(pattern, text):
result["phones"].append((match.start(), match.end(), match.group()))
# Detect addresses
for pattern in ADDRESS_PATTERNS:
for match in re.finditer(pattern, text):
result["addresses"].append((match.start(), match.end(), match.group()))
# Detect other sensitive info
for pattern in OTHER_PATTERNS:
for match in re.finditer(pattern, text):
result["others"].append((match.start(), match.end(), match.group()))
# Remove duplicates and sort by position
for category in result:
result[category] = sorted(set(result[category]), key=lambda x: x[0])
return result
def mask_sensitive_info(text: str, mask_char: str = "*") -> str:
"""
Mask all detected sensitive information in text.
Args:
text: Original text
mask_char: Character to use for masking (default: *)
Returns:
Text with sensitive info masked
"""
if not text:
return text
detected = detect_sensitive_info(text)
# Collect all ranges to mask
ranges = []
for category in detected.values():
for start, end, _ in category:
ranges.append((start, end))
# Merge overlapping ranges
ranges = sorted(ranges)
merged = []
for start, end in ranges:
if merged and start <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
# Apply masking (reverse order to preserve positions)
result = text
for start, end in reversed(merged):
original = result[start:end]
# Keep first and last char, mask the middle
if len(original) > 4:
masked = original[:2] + mask_char * (len(original) - 4) + original[-2:]
else:
masked = mask_char * len(original)
result = result[:start] + masked + result[end:]
return result
def highlight_sensitive_info(text: str) -> str:
"""
Add HTML highlighting to detected sensitive information.
Used for admin preview.
Returns:
HTML string with sensitive info wrapped in <mark> tags
"""
if not text:
return text
detected = detect_sensitive_info(text)
# Collect all ranges with their categories
ranges = []
for category, items in detected.items():
for start, end, matched in items:
ranges.append((start, end, matched, category))
# Sort by position (reverse for replacement)
ranges = sorted(ranges, key=lambda x: x[0], reverse=True)
result = text
for start, end, matched, category in ranges:
color = {
"phones": "#fee2e2", # red-100
"addresses": "#fef3c7", # amber-100
"others": "#dbeafe" # blue-100
}.get(category, "#e5e7eb")
result = (
result[:start] +
f'<mark style="background-color: {color}; padding: 0 2px;">{matched}</mark>' +
result[end:]
)
return result
def has_sensitive_info(text: str) -> bool:
"""Check if text contains any sensitive information."""
if not text:
return False
detected = detect_sensitive_info(text)
return any(len(items) > 0 for items in detected.values())
def get_sensitivity_summary(text: str) -> Dict[str, int]:
"""Get count of each type of sensitive info detected."""
if not text:
return {"phones": 0, "addresses": 0, "others": 0, "total": 0}
detected = detect_sensitive_info(text)
counts = {k: len(v) for k, v in detected.items()}
counts["total"] = sum(counts.values())
return counts

View File

@@ -0,0 +1,364 @@
"""
Specification Service for fetching vehicle specifications from AUTOBEGINS via Carmodoo
Uses Playwright to interact with the dealer portal and call AUTOBEGINS API
"""
import os
import re
import asyncio
import logging
import json
from typing import Optional, Dict, Any
from dataclasses import dataclass, field
# Configure logging
logger = logging.getLogger(__name__)
# Playwright imports
try:
from playwright.async_api import async_playwright, Browser, Page
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
logger.warning("Playwright not installed. Specification lookup will not work.")
# Carmodoo credentials
CARMODOO_BASE_URL = "https://dealer.carmodoo.com"
CARMODOO_USER_ID = os.getenv("CARMODOO_USER_ID", "01033315258")
CARMODOO_PASSWORD = os.getenv("CARMODOO_PASSWORD", "alskfl@1122")
@dataclass
class CarSpecification:
"""Vehicle specification data from AUTOBEGINS"""
car_number: str = ""
manufacturer: str = ""
model_name: str = ""
grade: str = ""
model_year: str = ""
first_registration: str = ""
body_type: str = ""
transmission: str = ""
fuel_type: str = ""
displacement: int = 0
color: str = ""
mileage: int = 0
usage: str = ""
vin: str = ""
inspection_validity: str = ""
# Price info (in 만원)
release_price: int = 0
base_price: int = 0
option_price: int = 0
# Mortgage/Seizure
mortgage_count: int = 0
seizure_count: int = 0
# Options
standard_options: list = field(default_factory=list)
selected_options: list = field(default_factory=list)
# Raw data
raw_data: dict = field(default_factory=dict)
def _parse_spec_html(html: str, car_number: str) -> CarSpecification:
"""Parse HTML content from AUTOBEGINS search.html to extract specification data"""
spec = CarSpecification(car_number=car_number)
spec.raw_data = {"html_length": len(html)}
try:
# Manufacturer and Model (from logo and text)
model_match = re.search(r'<ul class="model">\s*<li>.*?([^>]+)<br>([^<]+)</li>', html, re.DOTALL)
if model_match:
# Extract manufacturer from text before <br>
maker_text = model_match.group(1).strip()
maker_clean = re.sub(r'<[^>]+>', '', maker_text).strip()
spec.manufacturer = maker_clean
# Model name after <br>
spec.model_name = model_match.group(2).strip()
# Alternative manufacturer detection
if not spec.manufacturer:
maker_patterns = ['기아', '현대', 'KG모빌리티', '쌍용', '르노', '쉐보레', 'BMW', '벤츠', '아우디', '볼보', '렉서스', '토요타']
for maker in maker_patterns:
if maker in html:
spec.manufacturer = maker
break
# Year (년형)
year_match = re.search(r'<th>년형</th>\s*<td>(\d{4})년</td>', html)
if year_match:
spec.model_year = year_match.group(1)
# First registration (최초등록일)
reg_match = re.search(r'<th>최초등록일</th>\s*<td>(\d{4}\.\d{2}\.\d{2})</td>', html)
if reg_match:
spec.first_registration = reg_match.group(1)
# Body type (외형)
body_match = re.search(r'<th>외형</th>\s*<td>([^<]+)</td>', html)
if body_match:
spec.body_type = body_match.group(1).strip()
# Transmission (미션)
trans_match = re.search(r'<th>미션</th>\s*<td>([^<]+)</td>', html)
if trans_match:
spec.transmission = trans_match.group(1).strip()
# Fuel type (연료)
fuel_match = re.search(r'<th>연료</th>\s*<td>([^<]+)</td>', html)
if fuel_match:
spec.fuel_type = fuel_match.group(1).strip()
# Displacement (배기량)
disp_match = re.search(r'<th>배기량</th>\s*<td>(\d+)cc</td>', html)
if disp_match:
spec.displacement = int(disp_match.group(1))
# Color (색상)
color_match = re.search(r'<th>색상</th>\s*<td>([^<]+)</td>', html)
if color_match:
spec.color = color_match.group(1).strip()
# Mileage (주행거리)
mileage_match = re.search(r'<th>주행거리</th>\s*<td>([\d,]+)km</td>', html)
if mileage_match:
spec.mileage = int(mileage_match.group(1).replace(',', ''))
# Usage (용도)
usage_match = re.search(r'<th>용도</th>\s*<td>([^<]+)</td>', html)
if usage_match:
spec.usage = usage_match.group(1).strip()
# VIN (차대번호)
vin_match = re.search(r'value="([A-Z0-9]{17})"', html)
if vin_match:
spec.vin = vin_match.group(1)
# Inspection validity (검사유효기간)
insp_match = re.search(r'<th>검사유효기간</th>\s*<td[^>]*>([^<]+)</td>', html)
if insp_match:
spec.inspection_validity = insp_match.group(1).strip()
# Price extraction - digit_area contains nested spans with hidden digits
# Format: <span class="digit_area red">...<span class="hide">1</span>...<span class="hide">4</span>...</span>
def extract_price_from_section(section_html):
"""Extract price from a section of HTML containing digit_area spans"""
digits = re.findall(r'<span class="hide">([0-9])</span>', section_html)
if digits:
try:
return int(''.join(digits))
except:
pass
return 0
# Release price (출고가) - find the whole price_table row
release_section = re.search(r'출고가.*?</td>', html, re.DOTALL)
if release_section:
spec.release_price = extract_price_from_section(release_section.group(0))
# Base price (기본가)
base_section = re.search(r'>기본가<.*?</td>', html, re.DOTALL)
if base_section:
spec.base_price = extract_price_from_section(base_section.group(0))
# Option price (출고시 옵션가)
option_section = re.search(r'출고시 옵션가.*?</td>', html, re.DOTALL)
if option_section:
spec.option_price = extract_price_from_section(option_section.group(0))
# Mortgage/Seizure (저당/압류)
mortgage_match = re.search(r'<span class="title_big">저당</span>\s*<strong[^>]*>(\d+)</strong>', html)
if mortgage_match:
spec.mortgage_count = int(mortgage_match.group(1))
seizure_match = re.search(r'<span class="title_big">압류</span>\s*<strong[^>]*>(\d+)</strong>', html)
if seizure_match:
spec.seizure_count = int(seizure_match.group(1))
# Standard options (기본품목)
std_opts = re.findall(r'<ul class="opt_base">.*?</ul>', html, re.DOTALL)
if std_opts:
spec.standard_options = re.findall(r'<span>([^<]+)</span>', std_opts[0])
# Selected options (선택품목)
sel_opts = re.findall(r'<li><span>([^<]+)</span>\s*<strong>([^<]+)</strong></li>', html)
spec.selected_options = [f"{name} ({price})" for name, price in sel_opts]
logger.info(f"Parsed spec for {car_number}: {spec.manufacturer} {spec.model_name}")
except Exception as e:
logger.error(f"Error parsing spec HTML: {e}")
import traceback
traceback.print_exc()
return spec
async def get_specifications_from_carmodoo(car_number: str, timeout: int = 60000) -> Optional[CarSpecification]:
"""
Fetch vehicle specifications from AUTOBEGINS via Carmodoo dealer portal
Args:
car_number: Korean license plate number (e.g., "117더3590")
timeout: Maximum wait time in milliseconds
Returns:
CarSpecification object or None if not found
"""
if not PLAYWRIGHT_AVAILABLE:
logger.error("Playwright not available for specification lookup")
return None
if not car_number or len(car_number) < 7:
logger.error(f"Invalid car number: {car_number}")
return None
try:
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
try:
# Login to Carmodoo
logger.info("Logging in to Carmodoo...")
await page.goto(f"{CARMODOO_BASE_URL}/member/login_v2.html", timeout=timeout)
await page.fill('input[name="id"]', CARMODOO_USER_ID)
await page.fill('input[name="passwd"]', CARMODOO_PASSWORD)
await page.click('input[value="LOGIN"]')
await page.wait_for_timeout(3000)
# Navigate to spec search page
logger.info("Navigating to spec search...")
await page.goto(f"{CARMODOO_BASE_URL}/info/search_ab.html", timeout=timeout)
await page.wait_for_timeout(3000)
# Find the AUTOBEGINS iframe
target_frame = None
for frame in page.frames:
if 'autobegins.com/cp/?k=' in frame.url:
target_frame = frame
break
if not target_frame:
logger.error("Could not find AUTOBEGINS frame")
return None
# Get OTP values
otp = await target_frame.evaluate("document.getElementById('otp').value")
next_otp = await target_frame.evaluate("document.getElementById('nextOtp').value")
logger.info(f"Calling AUTOBEGINS API for: {car_number}")
# Call the API directly
api_result = await target_frame.evaluate("""
async (params) => {
const { carNum, otp, nextOtp } = params;
try {
const response = await fetch('/ext/gg1_ab.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `mode=search&carNum=${encodeURIComponent(carNum)}&otp=${otp}&nextOtp=${nextOtp}`
});
const text = await response.text();
return { success: true, data: text };
} catch (e) {
return { success: false, error: e.message };
}
}
""", {"carNum": car_number, "otp": otp, "nextOtp": next_otp})
if not api_result.get('success'):
logger.error(f"API call failed: {api_result.get('error')}")
return None
# Parse API response
try:
data = json.loads(api_result['data'])
except json.JSONDecodeError:
logger.error(f"Failed to parse API response")
return None
rst_code = data.get('rst_code')
if rst_code != 1:
rst_msg = data.get('rst_msg', 'Unknown error')
logger.warning(f"AUTOBEGINS API returned: {rst_msg} (code: {rst_code})")
return None
# Get search result
sd_key = data.get('sdKey')
sd_type = data.get('sdType')
if sd_type not in [2, 3]:
logger.warning(f"Unexpected sdType: {sd_type}")
return None
# Navigate to result page
page_name = 'search_yet.html' if sd_type == 2 else 'search.html'
result_url = f'/cp/{page_name}?otp={otp}&nextOtp={next_otp}&S_SDDATA={sd_key}'
await target_frame.evaluate(f"""
document.getElementById('searchIFrame').src = '{result_url}';
""")
logger.info("Waiting for result page to load...")
await page.wait_for_timeout(8000)
# Find and read result frame
result_content = None
for frame in page.frames:
if page_name in frame.url and 'S_SDDATA' in frame.url:
result_content = await frame.content()
break
if not result_content:
logger.error("Could not find result frame content")
return None
# Parse the HTML
spec = _parse_spec_html(result_content, car_number)
logger.info(f"Successfully retrieved specs for {car_number}")
return spec
finally:
await browser.close()
except Exception as e:
logger.error(f"Error fetching specifications for {car_number}: {e}")
import traceback
traceback.print_exc()
return None
def spec_to_dict(spec: CarSpecification) -> dict:
"""Convert CarSpecification to dictionary for database storage"""
return {
"car_number": spec.car_number,
"manufacturer": spec.manufacturer,
"model_name": spec.model_name,
"grade": spec.grade,
"model_year": spec.model_year,
"first_registration": spec.first_registration,
"body_type": spec.body_type,
"transmission": spec.transmission,
"fuel_type": spec.fuel_type,
"displacement": spec.displacement,
"color": spec.color,
"mileage": spec.mileage,
"usage": spec.usage,
"vin": spec.vin,
"inspection_validity": spec.inspection_validity,
"release_price": spec.release_price,
"base_price": spec.base_price,
"option_price": spec.option_price,
"mortgage_count": spec.mortgage_count,
"seizure_count": spec.seizure_count,
"standard_options": spec.standard_options,
"selected_options": spec.selected_options,
"raw_data": spec.raw_data,
}

View File

@@ -0,0 +1,174 @@
"""
Azure Translator Service for dealer descriptions
Supports Korean → English, Mongolian, Russian direct translation
"""
import os
import httpx
from typing import Optional, Dict
import json
class AzureTranslationService:
"""Microsoft Azure Translator API Service"""
AZURE_ENDPOINT = "https://api.cognitive.microsofttranslator.com"
API_VERSION = "3.0"
def __init__(self):
self.api_key = os.getenv("AZURE_TRANSLATOR_KEY", "")
self.region = os.getenv("AZURE_TRANSLATOR_REGION", "koreacentral")
self._is_configured = bool(self.api_key)
@property
def is_configured(self) -> bool:
"""Check if Azure Translator API is configured"""
return self._is_configured
async def translate(self, text: str, target_lang: str, source_lang: str = "ko") -> Optional[str]:
"""
Translate text from source language to target language
Args:
text: Text to translate (Korean)
target_lang: Target language code (en, mn, ru)
source_lang: Source language code (default: ko)
Returns:
Translated text or None if failed
"""
if not self._is_configured:
print("[Translation] Azure Translator API not configured")
return None
if not text or not text.strip():
return ""
try:
url = f"{self.AZURE_ENDPOINT}/translate"
params = {
"api-version": self.API_VERSION,
"from": source_lang,
"to": target_lang
}
headers = {
"Ocp-Apim-Subscription-Key": self.api_key,
"Ocp-Apim-Subscription-Region": self.region,
"Content-Type": "application/json"
}
body = [{"text": text}]
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
url,
params=params,
headers=headers,
json=body
)
if response.status_code == 200:
result = response.json()
if result and len(result) > 0 and "translations" in result[0]:
translated = result[0]["translations"][0]["text"]
print(f"[Translation] Success: {source_lang} -> {target_lang}")
return translated
else:
error_msg = response.text
print(f"[Translation] API Error ({response.status_code}): {error_msg}")
return None
except Exception as e:
print(f"[Translation] Exception: {e}")
return None
async def translate_all_languages(self, text: str) -> Dict[str, Optional[str]]:
"""
Translate text to all supported languages (en, mn, ru) in a single API call
Args:
text: Korean text to translate
Returns:
Dictionary with translations: {
'en': '...',
'mn': '...',
'ru': '...'
}
"""
if not text or not text.strip():
return {'en': '', 'mn': '', 'ru': ''}
if not self._is_configured:
print("[Translation] Azure Translator API not configured")
return {'en': None, 'mn': None, 'ru': None}
try:
# Azure supports multiple target languages in a single call
url = f"{self.AZURE_ENDPOINT}/translate"
params = {
"api-version": self.API_VERSION,
"from": "ko",
"to": ["en", "mn", "ru"] # All three languages at once
}
headers = {
"Ocp-Apim-Subscription-Key": self.api_key,
"Ocp-Apim-Subscription-Region": self.region,
"Content-Type": "application/json"
}
body = [{"text": text}]
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
url,
params=params,
headers=headers,
json=body
)
if response.status_code == 200:
result = response.json()
if result and len(result) > 0 and "translations" in result[0]:
translations = result[0]["translations"]
result_dict = {'en': None, 'mn': None, 'ru': None}
for trans in translations:
lang = trans.get("to")
text_translated = trans.get("text")
if lang in result_dict:
result_dict[lang] = text_translated
print(f"[Translation] Success: ko -> en, mn, ru (batch)")
return result_dict
else:
error_msg = response.text
print(f"[Translation] API Error ({response.status_code}): {error_msg}")
return {'en': None, 'mn': None, 'ru': None}
except Exception as e:
print(f"[Translation] Exception: {e}")
return {'en': None, 'mn': None, 'ru': None}
# Singleton instance
_translation_service: Optional[AzureTranslationService] = None
def get_translation_service() -> AzureTranslationService:
"""Get or create the translation service singleton"""
global _translation_service
if _translation_service is None:
_translation_service = AzureTranslationService()
return _translation_service
async def translate_dealer_description(text: str) -> Dict[str, Optional[str]]:
"""
Convenience function to translate dealer description to all languages
Args:
text: Korean dealer description
Returns:
Dictionary with translations for en, mn, ru
"""
service = get_translation_service()
return await service.translate_all_languages(text)

View File

@@ -0,0 +1,313 @@
"""
Verification Service for Email and SMS
Handles sending and verifying codes for user authentication
"""
import random
import string
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta
from typing import Optional, Tuple
from sqlalchemy.orm import Session
from ..config import get_settings
from ..models.user import User, VerificationCode
settings = get_settings()
def generate_code(length: int = 6) -> str:
"""Generate a random numeric code"""
return ''.join(random.choices(string.digits, k=length))
def create_verification_code(
db: Session,
code_type: str, # 'email' or 'phone'
email: Optional[str] = None,
phone: Optional[str] = None,
user_id: Optional[int] = None,
purpose: str = "verification"
) -> VerificationCode:
"""Create a new verification code"""
# Invalidate any existing codes for this email/phone
if email:
db.query(VerificationCode).filter(
VerificationCode.email == email,
VerificationCode.code_type == code_type,
VerificationCode.verified_at.is_(None)
).delete()
if phone:
db.query(VerificationCode).filter(
VerificationCode.phone == phone,
VerificationCode.code_type == code_type,
VerificationCode.verified_at.is_(None)
).delete()
# Create new code
code = VerificationCode(
user_id=user_id,
email=email,
phone=phone,
code=generate_code(),
code_type=code_type,
purpose=purpose,
expires_at=datetime.utcnow() + timedelta(minutes=settings.VERIFICATION_CODE_EXPIRE_MINUTES)
)
db.add(code)
db.commit()
db.refresh(code)
return code
def verify_code(
db: Session,
code: str,
code_type: str,
email: Optional[str] = None,
phone: Optional[str] = None
) -> Tuple[bool, str]:
"""
Verify a code and return (success, message)
"""
query = db.query(VerificationCode).filter(
VerificationCode.code_type == code_type,
VerificationCode.verified_at.is_(None)
)
if email:
query = query.filter(VerificationCode.email == email)
if phone:
query = query.filter(VerificationCode.phone == phone)
verification = query.order_by(VerificationCode.created_at.desc()).first()
if not verification:
return False, "No verification code found. Please request a new one."
# Check if expired
if datetime.utcnow() > verification.expires_at.replace(tzinfo=None):
return False, "Verification code has expired. Please request a new one."
# Check attempts
if verification.attempts >= verification.max_attempts:
return False, "Too many failed attempts. Please request a new code."
# Check code
if verification.code != code:
verification.attempts += 1
db.commit()
remaining = verification.max_attempts - verification.attempts
return False, f"Invalid code. {remaining} attempts remaining."
# Success
verification.verified_at = datetime.utcnow()
db.commit()
return True, "Verification successful"
async def send_email_verification(
db: Session,
email: str,
user_id: Optional[int] = None,
language: str = "en"
) -> Tuple[bool, str]:
"""Send email verification code"""
# Check rate limit (1 email per minute)
recent = db.query(VerificationCode).filter(
VerificationCode.email == email,
VerificationCode.code_type == "email",
VerificationCode.created_at > datetime.utcnow() - timedelta(minutes=1)
).first()
if recent:
return False, "Please wait 1 minute before requesting another code."
# Create verification code
verification = create_verification_code(
db=db,
code_type="email",
email=email,
user_id=user_id
)
# Send email
try:
# Email templates by language
subjects = {
"en": "AutonetSellCar - Email Verification Code",
"ko": "AutonetSellCar - 이메일 인증 코드",
"mn": "AutonetSellCar - Имэйл баталгаажуулах код",
"ru": "AutonetSellCar - Код подтверждения email"
}
bodies = {
"en": f"""
Hello,
Your verification code is: {verification.code}
This code will expire in {settings.VERIFICATION_CODE_EXPIRE_MINUTES} minutes.
If you didn't request this code, please ignore this email.
Best regards,
AutonetSellCar Team
""",
"ko": f"""
안녕하세요,
인증 코드: {verification.code}
이 코드는 {settings.VERIFICATION_CODE_EXPIRE_MINUTES}분 후에 만료됩니다.
요청하지 않은 경우 이 이메일을 무시하세요.
감사합니다,
AutonetSellCar 팀
""",
"mn": f"""
Сайн байна уу,
Таны баталгаажуулах код: {verification.code}
Энэ код {settings.VERIFICATION_CODE_EXPIRE_MINUTES} минутын дараа хүчингүй болно.
Хэрэв та энэ кодыг хүсээгүй бол энэ имэйлийг үл тоомсорлоно уу.
Хүндэтгэсэн,
AutonetSellCar баг
""",
"ru": f"""
Здравствуйте,
Ваш код подтверждения: {verification.code}
Этот код истечет через {settings.VERIFICATION_CODE_EXPIRE_MINUTES} минут.
Если вы не запрашивали этот код, проигнорируйте это письмо.
С уважением,
Команда AutonetSellCar
"""
}
subject = subjects.get(language, subjects["en"])
body = bodies.get(language, bodies["en"])
# Check if SMTP is configured
if not settings.SMTP_USER or not settings.SMTP_PASSWORD:
# Development mode - just log the code
print(f"[DEV] Email verification code for {email}: {verification.code}")
return True, "Verification code sent (dev mode)"
# Send actual email
msg = MIMEMultipart()
msg['From'] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_FROM_EMAIL or settings.SMTP_USER}>"
msg['To'] = email
msg['Subject'] = subject
msg.attach(MIMEText(body, 'plain', 'utf-8'))
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
server.starttls()
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
server.send_message(msg)
return True, "Verification code sent to your email"
except Exception as e:
print(f"[ERROR] Failed to send email: {e}")
return False, f"Failed to send email: {str(e)}"
async def send_sms_verification(
db: Session,
phone: str,
user_id: Optional[int] = None,
language: str = "en"
) -> Tuple[bool, str]:
"""Send SMS verification code"""
# Normalize phone number
phone = phone.strip().replace(" ", "").replace("-", "")
if not phone.startswith("+"):
# Assume Mongolia if no country code
if phone.startswith("9") and len(phone) == 8:
phone = "+976" + phone
# Check rate limit (1 SMS per minute)
recent = db.query(VerificationCode).filter(
VerificationCode.phone == phone,
VerificationCode.code_type == "phone",
VerificationCode.created_at > datetime.utcnow() - timedelta(minutes=1)
).first()
if recent:
return False, "Please wait 1 minute before requesting another code."
# Create verification code
verification = create_verification_code(
db=db,
code_type="phone",
phone=phone,
user_id=user_id
)
# SMS messages by language
messages = {
"en": f"AutonetSellCar verification code: {verification.code}. Valid for {settings.VERIFICATION_CODE_EXPIRE_MINUTES} min.",
"ko": f"AutonetSellCar 인증 코드: {verification.code}. {settings.VERIFICATION_CODE_EXPIRE_MINUTES}분간 유효.",
"mn": f"AutonetSellCar баталгаажуулах код: {verification.code}. {settings.VERIFICATION_CODE_EXPIRE_MINUTES} мин хүчинтэй.",
"ru": f"Код подтверждения AutonetSellCar: {verification.code}. Действителен {settings.VERIFICATION_CODE_EXPIRE_MINUTES} мин."
}
message = messages.get(language, messages["en"])
try:
# Check if Twilio is configured
if not settings.TWILIO_ACCOUNT_SID or not settings.TWILIO_AUTH_TOKEN:
# Development mode - just log the code
print(f"[DEV] SMS verification code for {phone}: {verification.code}")
return True, "Verification code sent (dev mode)"
# Send actual SMS via Twilio
from twilio.rest import Client
client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)
client.messages.create(
body=message,
from_=settings.TWILIO_PHONE_NUMBER,
to=phone
)
return True, "Verification code sent to your phone"
except Exception as e:
print(f"[ERROR] Failed to send SMS: {e}")
return False, f"Failed to send SMS: {str(e)}"
def mark_email_verified(db: Session, user: User) -> None:
"""Mark user's email as verified"""
user.email_verified = True
user.email_verified_at = datetime.utcnow()
db.commit()
def mark_phone_verified(db: Session, user: User, phone: str) -> None:
"""Mark user's phone as verified and update phone number"""
user.phone = phone
user.phone_verified = True
user.phone_verified_at = datetime.utcnow()
db.commit()
def is_email_verified(user: User) -> bool:
"""Check if user's email is verified"""
return user.email_verified
def is_phone_verified(user: User) -> bool:
"""Check if user's phone is verified"""
return user.phone_verified

View File

@@ -0,0 +1,299 @@
"""
Visitor Tracking Service
- Tracks page visits with privacy-preserving IP hashing
- Parses user agent for device/browser info
- Geolocation using free ip-api.com service
"""
import hashlib
import httpx
import json
from datetime import datetime, timedelta
from typing import Optional, Dict
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..models.visitor import VisitorLog, VisitorDailyStats, VisitorSession
# IP Geolocation service (free, 45 req/min limit)
IP_API_URL = "http://ip-api.com/json/{ip}?fields=status,country,countryCode,regionName,city"
# Cache for IP geolocation results (in-memory, simple)
_geo_cache: Dict[str, Dict] = {}
_geo_cache_expiry: Dict[str, datetime] = {}
GEO_CACHE_TTL = timedelta(hours=24)
def hash_ip(ip: str) -> str:
"""Hash IP address for privacy"""
return hashlib.sha256(ip.encode()).hexdigest()
def hash_visitor(ip: str, user_agent: str) -> str:
"""Create unique visitor hash from IP + User-Agent"""
combined = f"{ip}:{user_agent}"
return hashlib.sha256(combined.encode()).hexdigest()
def parse_device_info(user_agent_string: str) -> Dict:
"""Parse user agent string for device/browser info"""
try:
from user_agents import parse as parse_user_agent
ua = parse_user_agent(user_agent_string)
# Determine device type
if ua.is_mobile:
device_type = "mobile"
elif ua.is_tablet:
device_type = "tablet"
else:
device_type = "desktop"
return {
"device_type": device_type,
"browser": ua.browser.family,
"browser_version": ua.browser.version_string,
"os": ua.os.family,
"os_version": ua.os.version_string,
}
except ImportError:
# Fallback if user-agents not installed
return {
"device_type": "unknown",
"browser": "unknown",
"browser_version": "",
"os": "unknown",
"os_version": "",
}
async def get_geo_info(ip: str) -> Optional[Dict]:
"""Get geographic info from IP address using free ip-api.com"""
# Check cache first
if ip in _geo_cache:
if datetime.now() < _geo_cache_expiry.get(ip, datetime.min):
return _geo_cache[ip]
# Skip private/local IPs
if ip.startswith(('127.', '192.168.', '10.', '172.16.', '172.17.', '172.18.', '172.19.',
'172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.',
'172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.', 'localhost', '::1')):
return {"country": "Local", "country_code": "LO", "region": "", "city": ""}
try:
async with httpx.AsyncClient() as client:
response = await client.get(
IP_API_URL.format(ip=ip),
timeout=5.0
)
if response.status_code == 200:
data = response.json()
if data.get("status") == "success":
result = {
"country": data.get("country", "Unknown"),
"country_code": data.get("countryCode", ""),
"region": data.get("regionName", ""),
"city": data.get("city", ""),
}
# Cache the result
_geo_cache[ip] = result
_geo_cache_expiry[ip] = datetime.now() + GEO_CACHE_TTL
return result
except Exception as e:
print(f"Geo lookup failed for {ip}: {e}")
return None
def extract_referrer_domain(referrer: str) -> Optional[str]:
"""Extract domain from referrer URL"""
if not referrer:
return None
try:
from urllib.parse import urlparse
parsed = urlparse(referrer)
return parsed.netloc or None
except:
return None
async def log_visit(
db: Session,
ip: str,
user_agent: str,
page_path: str,
page_title: Optional[str] = None,
referrer: Optional[str] = None,
session_id: Optional[str] = None,
user_id: Optional[int] = None,
utm_source: Optional[str] = None,
utm_medium: Optional[str] = None,
utm_campaign: Optional[str] = None,
) -> VisitorLog:
"""
Log a page visit
"""
# Hash IP for privacy
ip_hash = hash_ip(ip)
visitor_hash = hash_visitor(ip, user_agent)
# Parse device info
device_info = parse_device_info(user_agent)
# Get geo info (async)
geo_info = await get_geo_info(ip) or {}
# Extract referrer domain
referrer_domain = extract_referrer_domain(referrer)
# Create log entry
log = VisitorLog(
visitor_hash=visitor_hash,
ip_hash=ip_hash,
session_id=session_id,
user_id=user_id,
page_path=page_path,
page_title=page_title,
referrer=referrer,
referrer_domain=referrer_domain,
device_type=device_info["device_type"],
browser=device_info["browser"],
browser_version=device_info["browser_version"],
os=device_info["os"],
os_version=device_info["os_version"],
country=geo_info.get("country"),
country_code=geo_info.get("country_code"),
city=geo_info.get("city"),
region=geo_info.get("region"),
utm_source=utm_source,
utm_medium=utm_medium,
utm_campaign=utm_campaign,
)
db.add(log)
# Update or create session
if session_id:
session = db.query(VisitorSession).filter(
VisitorSession.session_id == session_id
).first()
if session:
session.last_page = page_path
session.page_count += 1
session.last_activity_at = datetime.utcnow()
if user_id and not session.user_id:
session.user_id = user_id
else:
session = VisitorSession(
session_id=session_id,
visitor_hash=visitor_hash,
user_id=user_id,
first_page=page_path,
last_page=page_path,
device_type=device_info["device_type"],
browser=device_info["browser"],
country=geo_info.get("country"),
)
db.add(session)
db.commit()
db.refresh(log)
return log
def aggregate_daily_stats(db: Session, date_str: str) -> Optional[VisitorDailyStats]:
"""
Aggregate visitor stats for a given date (YYYY-MM-DD)
Called by scheduled task
"""
# Query all visits for the date
visits = db.query(VisitorLog).filter(
func.date(VisitorLog.visited_at) == date_str
).all()
if not visits:
return None
total_visits = len(visits)
unique_visitors = len(set(v.visitor_hash for v in visits))
# Device breakdown
device_counts = {}
for v in visits:
device = v.device_type or "unknown"
device_counts[device] = device_counts.get(device, 0) + 1
# Browser breakdown
browser_counts = {}
for v in visits:
browser = v.browser or "unknown"
browser_counts[browser] = browser_counts.get(browser, 0) + 1
# Country breakdown
country_counts = {}
for v in visits:
country = v.country_code or "unknown"
country_counts[country] = country_counts.get(country, 0) + 1
# Top pages
page_counts = {}
for v in visits:
page_counts[v.page_path] = page_counts.get(v.page_path, 0) + 1
top_pages = sorted(
[{"path": k, "views": v} for k, v in page_counts.items()],
key=lambda x: x["views"],
reverse=True
)[:20]
# Top referrers
referrer_counts = {}
for v in visits:
if v.referrer_domain:
referrer_counts[v.referrer_domain] = referrer_counts.get(v.referrer_domain, 0) + 1
top_referrers = sorted(
[{"domain": k, "visits": v} for k, v in referrer_counts.items()],
key=lambda x: x["visits"],
reverse=True
)[:10]
# Create or update daily stats
existing = db.query(VisitorDailyStats).filter(
VisitorDailyStats.stat_date == date_str
).first()
if existing:
existing.total_visits = total_visits
existing.unique_visitors = unique_visitors
existing.device_breakdown = json.dumps(device_counts)
existing.browser_breakdown = json.dumps(browser_counts)
existing.country_breakdown = json.dumps(country_counts)
existing.top_pages = json.dumps(top_pages)
existing.top_referrers = json.dumps(top_referrers)
stats = existing
else:
stats = VisitorDailyStats(
stat_date=date_str,
total_visits=total_visits,
unique_visitors=unique_visitors,
device_breakdown=json.dumps(device_counts),
browser_breakdown=json.dumps(browser_counts),
country_breakdown=json.dumps(country_counts),
top_pages=json.dumps(top_pages),
top_referrers=json.dumps(top_referrers),
)
db.add(stats)
db.commit()
return stats
def cleanup_old_visitor_logs(db: Session, days: int = 90) -> int:
"""Delete visitor logs older than specified days"""
cutoff = datetime.now() - timedelta(days=days)
deleted = db.query(VisitorLog).filter(
VisitorLog.visited_at < cutoff
).delete()
db.commit()
return deleted