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

1
agent/src/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Carmodoo Agent

View File

@@ -0,0 +1,294 @@
"""
Carmodoo API Client - HTTP based car data extraction
"""
import asyncio
import re
import logging
from typing import Optional, Dict, List, Any
from dataclasses import dataclass, field
from datetime import datetime
import aiohttp
from lxml import etree
@dataclass
class CarmodooConfig:
base_url: str = "https://dealer.carmodoo.com"
check_url: str = "https://ck.carmodoo.com"
encoding: str = "euc-kr"
user_id: str = ""
password: str = ""
request_timeout: int = 30
request_delay: float = 0.5
max_retries: int = 3
retry_delay: int = 2
@dataclass
class CarMaker:
code: str
name: str
cho: str = ""
@dataclass
class CarModel:
code: str
name: str
maker_code: str
@dataclass
class CarDetail:
car_no: str
car_name: str
maker_code: str
model_code: str
year: int
month: Optional[int]
mileage: int
price: int
fuel: str
transmission: str
color: str
displacement: int
car_number: str
seize_count: int
collateral_count: int
options: List[str]
memo: str
dealer_memo: str
main_image: str
images: List[str]
thumbnails: List[str]
check_num: str
check_url: str
check_gubun: str
dealer_name: str
dealer_phone: str
shop_name: str
shop_tel: str
raw_data: Dict[str, Any] = field(default_factory=dict)
class CarmodooClient:
def __init__(self, config: CarmodooConfig):
self.config = config
self.logger = logging.getLogger('carmodoo')
self.session: Optional[aiohttp.ClientSession] = None
self.cookies: Dict[str, str] = {}
self.is_logged_in = False
self.last_session_refresh = None
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36',
'Accept-Language': 'ko-KR,ko;q=0.9',
}
async def __aenter__(self):
await self.create_session()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()
async def create_session(self):
if self.session is None or self.session.closed:
timeout = aiohttp.ClientTimeout(total=self.config.request_timeout)
self.session = aiohttp.ClientSession(timeout=timeout, headers=self.headers)
return self.session
async def close(self):
if self.session and not self.session.closed:
await self.session.close()
def _decode_response(self, content: bytes) -> str:
try:
return content.decode(self.config.encoding)
except UnicodeDecodeError:
try:
return content.decode('utf-8')
except UnicodeDecodeError:
return content.decode('latin-1')
def _clean_xml_bytes(self, content: bytes) -> bytes:
try:
text = content.decode(self.config.encoding)
except UnicodeDecodeError:
try:
text = content.decode('utf-8')
except UnicodeDecodeError:
text = content.decode('latin-1')
text = re.sub(r'^[0-9a-fA-F]+\r?\n', '', text, flags=re.MULTILINE)
text = text.strip()
if not text.startswith('<?xml'):
xml_start = text.find('<?xml')
if xml_start > 0:
text = text[xml_start:]
text = re.sub(r'encoding=["\'][^"\']*["\']', 'encoding="UTF-8"', text)
return text.encode('utf-8')
async def _request(self, method: str, url: str, **kwargs):
await self.create_session()
for attempt in range(self.config.max_retries):
try:
if attempt > 0:
await asyncio.sleep(self.config.retry_delay)
async with self.session.request(method, url, **kwargs) as response:
content = await response.read()
return response.status, content, dict(response.cookies)
except aiohttp.ClientError as e:
self.logger.warning(f"Request failed (attempt {attempt + 1}): {e}")
if attempt == self.config.max_retries - 1:
raise
raise Exception("Max retries exceeded")
async def login(self, user_id: Optional[str] = None, password: Optional[str] = None) -> bool:
user_id = user_id or self.config.user_id
password = password or self.config.password
if not user_id or not password:
self.logger.error("User ID and password are required")
return False
self.logger.info(f"Attempting login for user: {user_id}")
url = f"{self.config.base_url}/member/login_ok.html"
data = {
'prevURL': '',
'id': user_id,
'passwd': password,
'idSave': 'Y',
'button': 'LOGIN'
}
headers = {
**self.headers,
'Content-Type': 'application/x-www-form-urlencoded',
'Origin': self.config.base_url,
'Referer': f'{self.config.base_url}/member/login_v2.html',
}
try:
status, content, cookies = await self._request(
'POST', url, data=data, headers=headers, allow_redirects=False
)
text = self._decode_response(content)
if 'goMain' in text or 'PHPSESSID' in str(cookies):
self.cookies.update(cookies)
self.is_logged_in = True
self.last_session_refresh = datetime.now()
self.logger.info("Login successful")
return True
else:
self.logger.error("Login failed: unexpected response")
return False
except Exception as e:
self.logger.error(f"Login error: {e}")
return False
async def get_car_makers(self) -> List[CarMaker]:
url = f"{self.config.base_url}/common/ajax/AutoDBCode.html"
params = {'mode': 'getCarInit', 'ctl': 'car'}
headers = {
**self.headers,
'Accept': 'application/xml, text/xml, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
}
try:
status, content, _ = await self._request(
'GET', url, params=params, headers=headers, cookies=self.cookies
)
if status != 200:
return []
return self._parse_car_makers(content)
except Exception as e:
self.logger.error(f"Error getting car makers: {e}")
return []
def _parse_car_makers(self, content: bytes) -> List[CarMaker]:
makers = []
try:
xml_bytes = self._clean_xml_bytes(content)
root = etree.fromstring(xml_bytes)
for item in root.findall('.//item'):
key = item.findtext('key', '')
name = item.findtext('name', '')
cho = item.findtext('cho', '')
if key and name:
makers.append(CarMaker(code=key, name=name, cho=cho))
except Exception as e:
self.logger.error(f"Error parsing car makers: {e}")
return makers
async def get_car_models(self, maker_code: str) -> List[CarModel]:
url = f"{self.config.base_url}/common/ajax/AutoDBCode.html"
params = {
'mode': 'getCarModelInit',
'ctl': 'car',
'company': maker_code,
'selected': '',
}
headers = {
**self.headers,
'Accept': 'application/xml, text/xml, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
}
try:
status, content, _ = await self._request(
'GET', url, params=params, headers=headers, cookies=self.cookies
)
if status != 200:
return []
return self._parse_car_models(content, maker_code)
except Exception as e:
self.logger.error(f"Error getting car models: {e}")
return []
def _parse_car_models(self, content: bytes, maker_code: str) -> List[CarModel]:
models = []
try:
xml_bytes = self._clean_xml_bytes(content)
root = etree.fromstring(xml_bytes)
for item in root.findall('.//item'):
key = item.findtext('key', '')
name = item.findtext('name', '')
if key and name:
models.append(CarModel(code=key, name=name, maker_code=maker_code))
except Exception as e:
self.logger.error(f"Error parsing car models: {e}")
return models
def get_image_url(self, car_no: str, index: int = 0) -> str:
padded = car_no.zfill(9)
folder = f"{padded[0:3]}/{padded[3:6]}/{padded[6:9]}"
return f"{self.config.base_url}/data/__carPhoto/{folder}/cmcar_{index}.jpg"

159
agent/src/sync_agent.py Normal file
View File

@@ -0,0 +1,159 @@
"""
Carmodoo Sync Agent - Syncs makers/models from Carmodoo to AutonetSellCar Backend
"""
import asyncio
import os
import logging
import httpx
from dotenv import load_dotenv
from .carmodoo_client import CarmodooClient, CarmodooConfig
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('sync_agent')
class SyncAgent:
def __init__(self):
load_dotenv()
# Carmodoo config
self.carmodoo_config = CarmodooConfig(
user_id=os.getenv('CARMODOO_USER_ID', ''),
password=os.getenv('CARMODOO_PASSWORD', ''),
)
# Backend API
self.api_url = os.getenv('API_SERVER_URL', 'http://autonet-backend:8000/api')
self.api_key = os.getenv('AGENT_API_KEY', '')
self.carmodoo: CarmodooClient = None
self.http_client: httpx.AsyncClient = None
async def start(self):
"""Initialize connections"""
logger.info("Starting Sync Agent...")
self.carmodoo = CarmodooClient(self.carmodoo_config)
await self.carmodoo.create_session()
self.http_client = httpx.AsyncClient(timeout=30.0)
# Login to Carmodoo
if not await self.carmodoo.login():
logger.error("Failed to login to Carmodoo")
return False
logger.info("Sync Agent started successfully")
return True
async def stop(self):
"""Cleanup connections"""
if self.carmodoo:
await self.carmodoo.close()
if self.http_client:
await self.http_client.aclose()
logger.info("Sync Agent stopped")
async def sync_makers(self):
"""Sync car makers from Carmodoo to Backend"""
logger.info("Syncing car makers...")
makers = await self.carmodoo.get_car_makers()
logger.info(f"Found {len(makers)} makers from Carmodoo")
synced = 0
for maker in makers:
try:
response = await self.http_client.post(
f"{self.api_url}/cars/makers/",
json={
"code": maker.code,
"name": maker.name,
}
)
if response.status_code in [200, 201]:
synced += 1
except Exception as e:
logger.error(f"Error syncing maker {maker.code}: {e}")
logger.info(f"Synced {synced}/{len(makers)} makers")
return makers
async def sync_models(self, makers):
"""Sync car models for all makers"""
logger.info("Syncing car models...")
total_models = 0
synced = 0
for maker in makers:
await asyncio.sleep(0.5) # Rate limiting
models = await self.carmodoo.get_car_models(maker.code)
total_models += len(models)
# Get maker ID from backend
try:
response = await self.http_client.get(f"{self.api_url}/cars/makers/")
if response.status_code == 200:
backend_makers = response.json()
maker_id = None
for bm in backend_makers:
if bm['code'] == maker.code:
maker_id = bm['id']
break
if maker_id:
for model in models:
try:
resp = await self.http_client.post(
f"{self.api_url}/cars/models/",
json={
"code": model.code,
"maker_id": maker_id,
"name": model.name,
}
)
if resp.status_code in [200, 201]:
synced += 1
except Exception as e:
logger.error(f"Error syncing model {model.code}: {e}")
except Exception as e:
logger.error(f"Error getting makers from backend: {e}")
logger.debug(f"Maker {maker.name}: {len(models)} models")
logger.info(f"Synced {synced}/{total_models} models")
async def run_sync(self):
"""Run full sync"""
if not await self.start():
return
try:
# Sync makers
makers = await self.sync_makers()
# Sync models
await self.sync_models(makers)
logger.info("Sync completed successfully!")
except Exception as e:
logger.error(f"Sync error: {e}")
finally:
await self.stop()
async def main():
agent = SyncAgent()
await agent.run_sync()
if __name__ == '__main__':
asyncio.run(main())