Skip to content

Commit 95dd127

Browse files
authored
Merge pull request #13 from potenup-dekk/develop
Release v0.0.1
2 parents a6ef67f + f81d11c commit 95dd127

23 files changed

+1148
-1
lines changed

.github/workflows/deploy.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Deploy Crawler to EC2
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
jobs:
9+
deploy:
10+
runs-on: self-hosted
11+
12+
concurrency:
13+
group: crawler-deploy
14+
cancel-in-progress: true
15+
16+
steps:
17+
- name: 1. 최신 코드 가져오기 (Checkout)
18+
uses: actions/checkout@v4
19+
20+
- name: 2. 운영 디렉터리 준비 및 코드 동기화
21+
run: |
22+
mkdir -p /opt/crawler
23+
rsync -av --delete \
24+
--exclude 'logs/' \
25+
--exclude 'data/' \
26+
./ /opt/crawler/
27+
28+
- name: 3. 환경변수(.env) 파일 생성 (운영 경로에 생성)
29+
run: |
30+
cat << EOF > /opt/crawler/.env
31+
S3_BUCKET_NAME=${{ secrets.S3_BUCKET_NAME }}
32+
BATCH_API_URL=${{ secrets.BATCH_API_URL }}
33+
DELIVERY_MODE=${{ secrets.DELIVERY_MODE }}
34+
AWS_REGION=${{ secrets.AWS_REGION }}
35+
EOF
36+
37+
- name: 4. Docker Compose 재빌드 및 재실행
38+
run: |
39+
cd /opt/crawler
40+
docker compose down
41+
docker compose build --no-cache
42+
docker compose up -d
43+
44+
- name: 5. 사용하지 않는 도커 이미지 정리
45+
run: docker image prune -f

.gitignore

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,22 @@
1-
.env
1+
# 환경 설정
2+
.env
3+
4+
# SSH 키 파일 (보안상 절대 커밋 금지)
5+
*.pem
6+
*.key
7+
id_rsa
8+
id_rsa.pub
9+
10+
# 런타임 데이터 (로그, 상태 파일)
11+
data
12+
logs
13+
14+
# Python
15+
venv/
16+
__pycache__/
17+
*.pyc
18+
*.pyo
19+
20+
# macOS
21+
.DS_Store
22+
docs

Dockerfile

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
FROM mcr.microsoft.com/playwright/python:v1.58.0-jammy
2+
3+
ENV TZ=Asia/Seoul
4+
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
5+
6+
RUN DEBIAN_FRONTEND=noninteractive apt-get update -o Acquire::ForceIPv4=true && \
7+
DEBIAN_FRONTEND=noninteractive apt-get install -y -o Acquire::ForceIPv4=true cron tzdata && \
8+
rm -rf /var/lib/apt/lists/*
9+
10+
WORKDIR /app
11+
12+
COPY requirements.txt .
13+
RUN pip install --no-cache-dir -r requirements.txt
14+
RUN python -m playwright install chromium
15+
16+
COPY . .
17+
18+
COPY entrypoint.sh /app/entrypoint.sh
19+
RUN chmod +x /app/entrypoint.sh
20+
21+
CMD ["/app/entrypoint.sh"]

README.md

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# DEKK Crawler
2+
3+
## 데이터 파이프라인 아키텍처
4+
5+
```mermaid
6+
sequenceDiagram
7+
actor Crawler as 크롤링 서버
8+
participant API as DEKK 서버
9+
10+
Crawler->>API: 1️⃣ POST /batches<br/>{platform: "MUSINSA"}
11+
activate API
12+
API-->>Crawler: {batchId: 42}<br/>(상태: COLLECTING)
13+
deactivate API
14+
15+
loop 청크 단위 반복 전송 (20건씩)
16+
Crawler->>API: 2️⃣ POST /batches/42/raw-data<br/>[snap1, snap2, ...]
17+
activate API
18+
API-->>Crawler: 200 OK
19+
deactivate API
20+
end
21+
22+
Crawler->>API: 3️⃣ POST /batches/42/complete<br/>{totalCount: 87, completedAt: "..."}
23+
activate API
24+
API-->>Crawler: 200 OK<br/>(상태: COLLECTED)
25+
deactivate API
26+
```
27+
28+
---
29+
30+
## 프로젝트 구조
31+
32+
```
33+
DEKK-CRAWLER/
34+
├── main.py # 진입점 - Delta 크롤링 실행 (cron 주기 실행)
35+
├── initial_load.py # 최초 1회 대규모 수집 (약 1000건, 병렬)
36+
├── requirements.txt # Python 패키지 의존성
37+
├── .env # 환경 변수 설정
38+
├── .gitignore # Git 제외 파일 목록
39+
40+
├── Dockerfile # 크롤러 컨테이너 이미지 정의
41+
├── docker-compose.yml # Docker 컨테이너 오케스트레이션
42+
├── entrypoint.sh # 컨테이너 시작 스크립트
43+
├── crontab # 크론 스케줄 설정
44+
45+
├── core/ # 핵심 기능 모듈
46+
│ ├── __init__.py
47+
│ ├── config.py # 전역 설정 및 상수 정의
48+
│ ├── logger.py # 통합 로깅 시스템
49+
│ ├── pipeline.py # 크롤링 파이프라인 오케스트레이터
50+
│ ├── s3_uploader.py # 이미지 및 JSON 백업 S3 업로드
51+
│ ├── backup_handler.py # 원본 데이터 S3 백업 처리 (중복 제거)
52+
│ ├── state_manager.py # Delta Crawling 상태 관리 (last_snap_id)
53+
│ └── delivery/ # 데이터 전송 모듈
54+
│ ├── __init__.py # Delivery 팩토리
55+
│ ├── base.py # BaseDelivery 추상 클래스
56+
│ └── batch.py # BatchDelivery 구현체 (3-step API 전송)
57+
58+
├── crawlers/ # 플랫폼별 크롤러
59+
│ ├── __init__.py
60+
│ ├── base.py # BaseCrawler 추상 클래스
61+
└────── musinsa.py # MusinsaCrawler 구현체
62+
63+
```
64+
65+
**주요 파일 설명**:
66+
67+
| 파일/디렉토리 | 역할 |
68+
| ------------------------ | ----------------------------------------------------------- |
69+
| `main.py` | 크론 주기 실행 진입점, Delta Crawling 수행 |
70+
| `initial_load.py` | 최초 실행 시 대량 데이터 수집 (상태 파일 없을 때 자동 실행) |
71+
| `core/pipeline.py` | 크롤링 → 백업 → 전송 → 상태 갱신 전체 흐름 조율 |
72+
| `core/s3_uploader.py` | 이미지 WebP 변환/업로드, 원본 데이터 JSON.GZ 백업 |
73+
| `core/backup_handler.py` | 원본 데이터 추출 및 S3 백업 로직 (중복 제거) |
74+
| `core/state_manager.py` | 마지막 처리 ID 관리로 중복 수집 방지 |
75+
| `core/delivery/` | Batch API 3-step 전송 (배치 생성 → 데이터 전송 → 완료 통보) |
76+
| `crawlers/musinsa.py` | 무신사 스냅 크롤링 |
77+
| `entrypoint.sh` | 상태 파일 유무 확인 → 초기 수집 or cron 시작 분기 |
78+
79+
---
80+
81+
## 실행 흐름
82+
83+
```
84+
docker compose up
85+
└── entrypoint.sh
86+
├── [최초] crawler_state.json 없음
87+
│ └── initial_load.py 실행 (max_scrolls=40, ~1000건, 병렬 수집)
88+
│ └── 완료 후 last_snap_id 저장
89+
└── cron 시작
90+
└── */10 * * * * → main.py (신규 스냅만 delta 수집)
91+
```
92+
93+
---
94+
95+
## 상태 관리 (Delta Crawling)
96+
97+
> ### Delta Crawling (동적 수집 분기 전략)
98+
>
99+
> - **최초 실행 시 (상태 파일 없음)**: `entrypoint.sh``initial_load.py`를 자동 실행. Playwright로 최대 40회 스크롤하여 약 1000건 수집
100+
> - **이후 실행 시 (상태 파일 있음)**: `last_snap_id`를 만날 때까지의 신규 스냅만 가볍게 수집 (10분 주기)
101+
102+
`/app/data/crawler_state.json`에 플랫폼별 마지막 처리 snap ID를 저장합니다.
103+
104+
```json
105+
{
106+
"MUSINSA": "12345678"
107+
}
108+
```
109+
110+
Docker volume(`./data:/app/data`)으로 마운트되어 컨테이너 재시작 시에도 상태가 유지됩니다.
111+
112+
#### 데이터 유실 방지 (Data Loss Prevention) 및 멱등성 보장
113+
114+
- **상태 갱신 지연**: 크롤링 즉시 상태를 갱신하지 않습니다.
115+
- **안전한 커밋**: Batch API 전송이 최종적으로 성공(`complete` 호출 완료)했을 때만 `last_snap_id`를 갱신합니다.
116+
- **자동 복구**: 네트워크 오류 시 상태가 갱신되지 않으므로, 다음 크론 주기(10분 뒤)에 동일한 데이터를 안전하게 재수집하여 재전송을 시도합니다.
117+
118+
---
119+
120+
## Batch API 전송 흐름
121+
122+
크롤러는 3단계로 데이터를 전송합니다:
123+
124+
1. **배치 생성** `POST /batches` - 플랫폼 정보와 함께 배치 생성, `batchId` 수신
125+
2. **데이터 전송** `POST /batches/{batchId}/raw-data` - 수집한 스냅 데이터를 청크(20건) 단위로 반복 전송
126+
3. **완료 통보** `POST /batches/{batchId}/complete` - 전송 완료 및 총 개수 전달
127+
128+
---
129+
130+
## 로깅
131+
132+
`core/logger.py`에서 싱글턴 로거를 생성합니다. 로그는 `/app/data/` 하위에 기록됩니다.
133+
134+
| 파일 | 내용 |
135+
| ----------------------- | ---------------------------------------- |
136+
| `/app/logs/crawler.log` | INFO 이상 전체 로그 |
137+
| `/app/logs/error.log` | ERROR 이상만 |
138+
| 콘솔 (stdout) | INFO 이상 전체 (Docker 로그로 확인 가능) |
139+
140+
---
141+
142+
## 환경 변수
143+
144+
`.env` 파일을 프로젝트 루트에 생성하세요.
145+
146+
```dotenv
147+
# Batch API 서버 주소
148+
BATCH_API_URL=http://your-spring-boot-server/api
149+
150+
# Delivery 모드 (현재 BATCH만 지원)
151+
DELIVERY_MODE=BATCH
152+
153+
# AWS S3 (크롤링한 이미지 저장)
154+
AWS_REGION=ap-northeast-2
155+
S3_BUCKET_NAME=your-bucket-name
156+
157+
# AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY는 EC2/ECS IAM Role 사용 시 불필요
158+
# 로컬 테스트 시에만 직접 설정
159+
```
160+
161+
---
162+
163+
## 실행 방법
164+
165+
```bash
166+
# 빌드 및 실행
167+
# - 최초: initial_load.py로 ~1000건 수집 후 cron 시작
168+
# - 재시작: 상태 파일이 있으면 바로 cron 시작
169+
docker compose up -d --build
170+
171+
# 로그 확인
172+
docker logs -f integrated-crawler-worker
173+
```
174+
175+
---
176+
177+
## 의존성
178+
179+
| 패키지 | 버전 | 용도 |
180+
| ---------------- | ------- | -------------------------------------------------------------- |
181+
| `requests` | 2.32.5 | Batch API HTTP 요청 (배치 생성, 데이터 전송, 완료 통보) |
182+
| `boto3` | 1.42.56 | AWS S3 연동 (이미지 업로드 및 원본 데이터 JSON.GZ 백업) |
183+
| `beautifulsoup4` | 4.14.3 | HTML 파싱 (`__NEXT_DATA__` 스크립트 태그에서 JSON 추출) |
184+
| `playwright` | 1.58.0 | 무신사 스냅 목록 페이지 동적 스크롤 (Headless Chromium) |
185+
| `python-dotenv` | 1.0.1 | `.env` 파일에서 환경 변수 로딩 |
186+
| `curl_cffi` | 0.7.4 | TLS 핑거프린팅 우회 (무신사 스냅 상세 페이지 및 상품 API 호출) |
187+
| `Pillow` | 11.0.0 | 이미지 처리 (WebP 변환 및 리사이징) |

core/__init__.py

Whitespace-only changes.

core/backup_handler.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from core.logger import logger
2+
from core.s3_uploader import S3Uploader
3+
4+
5+
def backup_raw_data(batch_raw_data_list: list, platform: str, crawled_at: str) -> bool:
6+
"""
7+
수집한 데이터에서 원본을 추출하여 S3에 백업
8+
9+
Args:
10+
batch_raw_data_list: 처리된 크롤링 데이터 리스트 (각 항목은 _original_raw_data 필드 포함 가능)
11+
platform: 플랫폼명 (예: 'MUSINSA')
12+
crawled_at: 크롤링 시각 (ISO format, ':' 포함)
13+
14+
Returns:
15+
bool: 백업 성공 여부
16+
"""
17+
if not batch_raw_data_list:
18+
logger.warning(f"[{platform}] 백업할 데이터가 없습니다.")
19+
return False
20+
21+
original_data_list = []
22+
for item in batch_raw_data_list:
23+
if '_original_raw_data' in item:
24+
original_data_list.append(item.pop('_original_raw_data'))
25+
else:
26+
# 혹시 원본 필드가 없으면 현재 데이터를 백업
27+
original_data_list.append(item.copy())
28+
29+
safe_crawled_at = crawled_at.replace(':', '-')
30+
backup_key = f"backups/raw-data/{platform.lower()}/original_{safe_crawled_at}_{len(original_data_list)}.json"
31+
32+
backup_s3_key = S3Uploader().upload_json_backup(original_data_list, backup_key)
33+
34+
if backup_s3_key:
35+
logger.info(f"[{platform}] 원본 데이터 S3 백업 완료: {backup_s3_key}")
36+
return True
37+
else:
38+
logger.warning(f"[{platform}] 원본 데이터 S3 백업 실패 (배치 전송은 계속 진행)")
39+
return False

core/config.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import os
2+
3+
BASE_DIR = "/app"
4+
5+
DATA_DIR = os.path.join(BASE_DIR, 'data')
6+
LOG_DIR = os.path.join(BASE_DIR, 'logs')
7+
8+
STATE_FILE_PATH = os.path.join(DATA_DIR, "crawler_state.json")
9+
10+
os.makedirs(DATA_DIR, exist_ok=True)
11+
os.makedirs(LOG_DIR, exist_ok=True)
12+
13+
# ── 파이프라인 설정 ───────────────────────────────────────
14+
MAX_WORKERS = 5
15+
CHUNK_SIZE = 20
16+
INITIAL_MAX_SCROLLS = 40
17+
18+
# ── 공통 네트워크 ─────────────────────────────────────────
19+
CURL_IMPERSONATE = "chrome110"
20+
BROWSER_USER_AGENT = (
21+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
22+
"AppleWebKit/537.36 (KHTML, like Gecko) "
23+
"Chrome/120.0.0.0 Safari/537.36"
24+
)
25+
26+
# ── Musinsa 크롤러 ────────────────────────────────────────
27+
PLAYWRIGHT_TIMEOUT_MS = 10_000
28+
VIEWPORT_SIZE = {'width': 1920, 'height': 1080}
29+
30+
SNAP_REQUEST_TIMEOUT = 15 # 스냅 상세 페이지 요청 (초)
31+
GOODS_REQUEST_TIMEOUT = 10 # 상품 배치 API 요청 (초)
32+
33+
PROCESS_SLEEP_RANGE = (1.5, 3.5) # 스냅 처리 전 대기 (방화벽 회피)
34+
SCROLL_SLEEP_RANGE = (1.5, 3.0) # 페이지 스크롤 후 대기
35+
36+
SNAP_IMAGE_SIZE = (450, 675) # 스냅 이미지 리사이즈 목표 크기
37+
GOODS_IMAGE_SIZE = (100, 100) # 상품 이미지 리사이즈 목표 크기
38+
39+
# ── S3 업로더 ─────────────────────────────────────────────
40+
IMAGE_DOWNLOAD_TIMEOUT = 30 # 이미지 다운로드 타임아웃 (초)
41+
IMAGE_DOWNLOAD_MAX_RETRIES = 3
42+
RETRY_SLEEP = 2 # 재시도 대기 시간 (초)
43+
WEBP_QUALITY = 80

core/delivery/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import os
2+
3+
from .base import BaseDelivery
4+
from .batch import BatchDelivery
5+
6+
7+
def get_delivery() -> BaseDelivery:
8+
mode = os.getenv('DELIVERY_MODE', 'BATCH').upper()
9+
if mode == 'BATCH':
10+
return BatchDelivery()
11+
raise ValueError(f"지원하지 않는 DELIVERY_MODE: {mode}")

core/delivery/base.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from abc import ABC, abstractmethod
2+
3+
4+
class BaseDelivery(ABC):
5+
6+
@abstractmethod
7+
def create_batch(self, platform: str) -> int:
8+
pass
9+
10+
@abstractmethod
11+
def send_raw_data(self, batch_id: int, chunk_list: list, crawled_at: str) -> None:
12+
pass
13+
14+
@abstractmethod
15+
def complete_batch(self, batch_id: int, total_count: int, completed_at: str, error_message: str = None) -> None:
16+
pass

0 commit comments

Comments
 (0)