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:
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"
|
||||
Reference in New Issue
Block a user