개인 프로젝트/DayCar 프로젝트
[DayCar] 리팩토링 일지 - 3편: 스크래퍼 개발
안기용
2025. 4. 17. 19:31
아키텍처가 나왔으니 개발을 진행합니다.
1. 크롤러 설계 방향
- 수집 대상: 보배드림, K카 등 주요 중고차 거래 사이트
- 설계 목표
- 비동기 기반의 효율적인 수집
- 관심사 분리(fetch / parse / load)
- 사이트 추가 시 최소 변경만으로 확장 가능
2. 디렉토리 구조
scraper/
├── core/ # 공통 fetch, parse, load 인터페이스 정의
│ ├── fetcher.py
│ ├── parser.py
│ ├── loader.py
│ └── __init__.py
├── fetcher/ # 실제 페이지 요청 구현 (Playwright 등)
│ └── playwright_fetcher.py
├── parser/ # 사이트별 파서 정의
│ ├── a_parser.py
│ └── b_parser.py
├── loader/ # 추후 GCS, MinIO 등 저장소 확장 고려
├── pipeline/ # 전체 흐름을 조율하는 파이프라인 정의
│ └── pipelines.py
├── redis_client.py # Redis 연결 및 큐 작업
├── main.py # 실행 엔트리포인트
└── spark-jobs/ # 후처리 Spark Job
└── etl/preprocessing.py
3. 컴포넌트 설명
✅ core/
- 추상 클래스 또는 공통 인터페이스
- 모든 fetcher/parser/loader의 베이스가 되는 모듈
✅ Fetcher
- Playwright 기반으로 페이지를 비동기적으로 로드하고 HTML을 반환하는 모듈입니다.
# fetcher/playwright_fetcher.py
from playwright.async_api import async_playwright
import asyncio
import random
class PlaywrightFetcher:
async def __aenter__(self):
self.playwright = await async_playwright().start()
self.browser = await self.playwright.chromium.launch(headless=True)
self.page = await self.browser.new_page()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.browser.close()
await self.playwright.stop()
async def fetch(self, url: str) -> str:
await self.page.goto(url, timeout=30000)
await asyncio.sleep(random.uniform(1, 2)) # anti-ban
return await self.page.content()
✅ parser/
- BeautifulSoup을 사용해 사이트별 HTML 구조를 파싱하고, 필요한 필드를 추출합니다.
# parser/bobae_dream.py
from bs4 import BeautifulSoup
class BobaeParser(BaseParser):
async def parse(self, data: str):
soup = bs(data, "html.parser")
records = []
for product in soup.select("ul.clearfix li.product-item"):
inner = product.select_one("div.list-inner")
if not inner:
continue
record = ()
for cell in inner.select("div.mode-cell"):
classes = [c for c in cell.get("class", []) if c != "mode-cell"]
if "title" in classes:
title = cell.select_one("p.tit a").get_text(strip=True)
subtitle_tag = cell.select_one("p.stxt a")
subtitle = subtitle_tag.get_text(strip=True) if subtitle_tag else ""
record += (title, subtitle)
continue
if "seller" in classes:
record += self._parse_seller(cell)
continue
if classes:
record += (cell.get_text(strip=True),)
records.append(record)
return records
✅ main
- 전체 수집 흐름 예시입니다.
# main.py
import asyncio
from fetcher.playwright\_fetcher import PlaywrightFetcher
from parser.bobae\_dream import BobaeDreamParser
from loader.redis\_loader import RedisLoader
async def main():
url = "[https://example.com/cars?page=1"](https://example.com/cars?page=1")
async with PlaywrightFetcher() as fetcher:
html = await fetcher.fetch(url)
records = BobaeDreamParser().parse(html)
RedisLoader().save(records)
if name == "__main__":
asyncio.run(main())
4. 개발 시 중점 사항
- 모듈화: 공통 로직과 사이트별 로직을 철저히 분리
- 비동기 처리: Playwright + asyncio 조합으로 성능 확보
- 확장성: 새로운 사이트 추가가 매우 간단함 (
parser/
에 파일 추가만)