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:
1
agent/src/__init__.py
Normal file
1
agent/src/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Carmodoo Agent
|
||||
294
agent/src/carmodoo_client.py
Normal file
294
agent/src/carmodoo_client.py
Normal 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
159
agent/src/sync_agent.py
Normal 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())
|
||||
Reference in New Issue
Block a user