feat(lps): 오픈마켓 폴백 기본 비활성(LPS_FALLBACKS 토글) — 협의 결정 B안 적용

2026-07-10 협의: 검색은 네이버+쿠팡만. G마켓·옥션·11번가 폴백은 최종 최저가
기여 0회에 검색당 최대 15s·프록시 비용 ~87%를 차지해 로직에서 제외.
주석처리 대신 env 토글로 코드·테스트는 살려둔다(부패 방지·env 한 줄 재가동).

- worker_main: LPS_FALLBACKS(기본 빈값=OFF)로만 폴백 어댑터 생성, 잘못된 값 경고,
  기동 로그에 폴백 상태 표기. 핸들러는 빈 폴백을 원래 정상 처리라 로직 변경 없음
- run_local_worker.sh: 폴백 여부 대화형 질문 추가(기본 비활성)
- compose: LPS_FALLBACKS 주석 env(재가동용)
- decision-openmarket-crawler.md: 결정(B)·근거·재가동 절차(라이브 스모크 선행) 확정 기록
- README·architecture·operations: 기본 비활성 반영

검증: 전체 테스트 106 passed(폴백 로직 테스트는 fake 주입이라 계속 유효),
워커 실기동 로그 '오픈마켓 폴백: OFF' 확인

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-10 10:14:39 +09:00
parent 89fe2c9805
commit 5e485e9b6e
7 changed files with 50 additions and 18 deletions

View File

@ -148,6 +148,7 @@ services:
PYTHONUNBUFFERED: "1"
WORKER_CONCURRENCY: "1" # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 4×N)
LPS_PROFILE_DIR: /profiles # Chrome 프로필을 영속 볼륨에 → 재시작해도 cf_clearance 유지(재웜업 회피)
# LPS_FALLBACKS: "gmarket,auction,st11" # 오픈마켓 폴백(기본 OFF — 켜기 전 라이브 스모크로 셀렉터 점검)
# ── 프로덕션 시크릿 주입(이미지에 안 굽고 env 로). 아래 주석 해제 후 값 채우거나 secrets 매니저 연동 ──
# OPENAI_API_KEY: ${OPENAI_API_KEY}
# DECODO_USERNAME: ${DECODO_USERNAME}

View File

@ -25,7 +25,7 @@
가격 이상치 제거 → "같은 상품"만 선별
│
▼
[4-1] 오픈마켓 폴백
[4-1] 오픈마켓 폴백 (옵션 · 기본 꺼짐)
네이버가 못 덮은 몰(G마켓·옥션·11번가)만 크롤
│
▼
@ -49,7 +49,7 @@
| 기능 | 설명 |
|------|------|
| 멀티 소스 검색 | 네이버 쇼핑 API + 쿠팡(Akamai 우회) 동시 검색·병합 |
| 오픈마켓 폴백 크롤 | 네이버가 못 덮은 몰만 G마켓·옥션(Cloudflare Turnstile 우회)·11번가 크롤 → 몰별 가격 |
| 오픈마켓 폴백 크롤 | 네이버가 못 덮은 몰만 G마켓·옥션(Cloudflare Turnstile 우회)·11번가 크롤 → 몰별 가격. **기본 비활성**(`LPS_FALLBACKS`, [배경](docs/decision-openmarket-crawler.md)) |
| AI 같은 상품 판정 | "진짜 그 상품"만 선별 (액세서리·다른 규격 제외) |
| 검색어 자동 정제 | 0건이면 정밀/광역 검색어로 재시도 |
| 최저가 이력 그래프 | 조회 시점마다 네이버/쿠팡/최종 + 몰별(by_mall) 최저가를 시계열로 기록 |

View File

@ -10,7 +10,7 @@
| **큐(대기줄)** | `crud/job_crud.py` + `job` 테이블 | 할 일을 순서대로 안전하게 보관 (PostgreSQL 사용) |
| **워커(일꾼)** | `worker_main.py`, `worker/` | 큐에서 하나씩 꺼내 **실제 검색·판정·저장** 수행 |
| **소스 어댑터** | `services/search/` | 네이버·쿠팡에서 상품 수집 (소스별 방식 캡슐화) |
| **오픈마켓 폴백** | `services/search/{esm,st11}/` | G마켓·옥션·11번가 크롤 — 네이버가 그 몰을 커버 못 했을 때만 (BrowserSearchAdapter 공유) |
| **오픈마켓 폴백** | `services/search/{esm,st11}/` | G마켓·옥션·11번가 크롤 — 네이버가 그 몰을 커버 못 했을 때만 (BrowserSearchAdapter 공유). **기본 비활성**(`LPS_FALLBACKS`) |
| **파이프라인** | `services/pipeline/` | 수집 결과를 필터·이상치 제거·최저가 정렬 |
| **AI** | `services/ai/` | "같은 상품" 판정 + 검색어 생성 (OpenAI) |
@ -41,9 +41,11 @@
→ 매칭 0건 + 소스 정상: 다음 라운드로
→ 매칭 0건 + 소스 차단: 작업 실패 처리(뒤에서 재시도)
⑤-1 오픈마켓 폴백 (매칭 성공 시)
⑤-1 오픈마켓 폴백 (매칭 성공 시 · **기본 비활성 — LPS_FALLBACKS 로 켬**)
네이버가 커버 못 한 몰(G마켓·옥션·11번가)만 실사이트 크롤 → 같은 상품 판정 → 병합
("네이버로 그 몰 값 확보 성공 → 그 값, 실패(몰 없음) → 크롤". 크롤 실패는 격리)
※ 2026-07-10 협의: 최종 최저가 기여 0회·시간/비용 과다로 로직에서 제외(코드 유지).
배경·재가동 절차는 decision-openmarket-crawler.md
⑥ 결과 저장
최저가 확정 + 최저가 이력 스냅샷 기록(몰별 by_mall 포함)

View File

@ -1,6 +1,6 @@
# 논의: 오픈마켓 크롤러를 계속 안고 갈까?
> 상태: **논의 필요** · 작성 2026-07-09 · 대상: 개발팀
> 상태: **결정 완료(B. 게이트/OFF)** · 작성 2026-07-09 · 결정 2026-07-10 · 대상: 개발팀
> 한 줄: G마켓·옥션·11번가 **크롤러가 비용·복잡성의 ~80%인데, 실제 값어치(ROI)가 미증명**이라 유지 여부를 정해야 함.
---
@ -56,8 +56,13 @@ LPS는 상품별 최저가를 찾는다. 소스는 2계층:
---
## 결론 초안 (논의 후 확정)
## 결론 (2026-07-10 개발자 협의로 확정)
- [ ] by_mall 데이터 소비처: __________
- [ ] 결정: A / B / C / D → __________
- [ ] (A 선택 시) TTL 값: ______ / (B 선택 시) 재개 트리거: ______
- [x] **결정: B. 게이트/OFF** — 검색은 **네이버+쿠팡만**. 오픈마켓 폴백 3종은 **코드·테스트 유지, 로직에서 제외(기본 비활성)**.
- 근거: 크롤 몰의 최종 최저가 기여 0회 + 검색당 최대 15s(폴백 데드라인) + 비용의 ~87%(DECODO)가 이 경로.
- [x] 구현: 주석처리가 아닌 **env 토글** — `LPS_FALLBACKS`(기본 빈값=OFF, 예: `gmarket,auction,st11`, 일부만도 가능).
- `worker_main.py` 가 이 값으로만 폴백 어댑터를 생성. 핸들러는 빈 폴백을 원래 정상 처리(`worker/handlers.py`)라 로직 변경 없음.
- 폴백 로직·파서 테스트는 fake 주입이라 **비활성 상태에서도 계속 돈다**(코드 부패 방지).
- by_mall 은 네이버 노출 몰 + 쿠팡으로만 채워짐 → 소비처(프론트) 연동 시 공유할 것.
- [x] 재개 트리거: by_mall 소비 화면이 생기거나, 특정 몰이 네이버 커버리지에서 빠져 가격 검증이 필요해질 때.
- **재가동 절차**: ① 라이브 스모크로 셀렉터 드리프트 점검(`LPS_LIVE=1 pytest tests/test_browser_base.py::test_live_smoke` + 대상 몰 1회 검색) → ② `LPS_FALLBACKS` 설정(로컬은 `run_local_worker.sh` 질문, 배포는 compose env) → ③ 웜업/차단 로그 확인. 미사용 기간 동안 셀렉터는 낡는다고 가정할 것.

View File

@ -35,10 +35,11 @@ psql -h 127.0.0.1 -U postgres -d lps_db -c "CREATE EXTENSION IF NOT EXISTS pgcry
**워커** (실제 검색 수행) — 별도 터미널
```bash
./run_local_worker.sh # 대화형: 동시성(WORKER_CONCURRENCY)·Chrome 프로필 선택
./run_local_worker.sh # 대화형: 동시성(WORKER_CONCURRENCY)·Chrome 프로필·폴백 선택
# 또는 직접:
PYTHONUNBUFFERED=1 python worker_main.py # 로그 실시간
WORKER_CONCURRENCY=3 python worker_main.py # 동시성 2~3(로컬). Chrome 최대 4×N개
LPS_FALLBACKS=gmarket,auction,st11 python worker_main.py # 오픈마켓 폴백 재가동(기본 OFF — decision 문서 참고)
```
> 워커 실행 시 쿠팡 크롤링용 **Chrome 창이 뜹니다**(정상). 기동 로그에 `DECODO 프리플라이트 OK — egress IP ...`, `AI: ON/OFF`가 표시됩니다.
> 동시성 N이면 상품 N개가 진짜 병렬 처리됩니다(각 워커가 자기 프로필·프록시 IP 사용).

View File

@ -46,13 +46,17 @@ read -rp "Chrome 프로필 디렉터리 LPS_PROFILE_DIR [.profiles] (엔터=유
PROFILE="${PROFILE:-.profiles}"
mkdir -p "$PROFILE"
# 6) 오픈마켓 폴백 — 기본 비활성(2026-07-10 협의: 최종 최저가 기여 0회, 검색당 최대 15s·프록시 비용의 대부분)
read -rp "오픈마켓 폴백 LPS_FALLBACKS [비활성] (켜려면 예: gmarket,auction,st11): " FALLBACKS
export LPS_FALLBACKS="${FALLBACKS:-}"
export APP_ENV=local
export WORKER_CONCURRENCY="$CONC"
export LPS_PROFILE_DIR="$PROFILE"
export PYTHONUNBUFFERED=1 # 로그 실시간 출력
echo ""
echo "[run] worker_main.py (동시성=$CONC · 프로필=$PROFILE)"
echo "[run] worker_main.py (동시성=$CONC · 프로필=$PROFILE · 폴백=${LPS_FALLBACKS:-OFF})"
echo " 기동 로그의 'DECODO 프리플라이트 OK — egress IP ...' / 'AI: ON/OFF' 확인"
echo " 중단: Ctrl+C"
echo ""

View File

@ -31,6 +31,21 @@ from worker.runner import Worker, run_reaper
LOG.SetPrefix(f"{web_server_config.server_name}-worker")
# 오픈마켓 폴백(G마켓·옥션·11번가)은 **기본 비활성** — 2026-07-10 협의 결정.
# 실측상 크롤 몰이 최종 최저가를 바꾼 적이 없고(0회), 검색당 최대 15s + 프록시 대역폭의
# 대부분을 차지해 로직에서 제외했다(코드·테스트는 유지, 핸들러는 빈 폴백을 정상 처리).
# 재가동: LPS_FALLBACKS=gmarket,auction,st11 (일부만도 가능) — 켜기 전 라이브 스모크로
# 셀렉터 드리프트 점검. 배경은 docs/decision-openmarket-crawler.md.
_FALLBACK_SOURCES = ("gmarket", "auction", "st11")
def _enabled_fallbacks() -> list[str]:
names = [s.strip() for s in os.environ.get("LPS_FALLBACKS", "").split(",") if s.strip()]
unknown = [n for n in names if n not in _FALLBACK_SOURCES]
if unknown:
LOG.w(f"LPS_FALLBACKS 무시된 값: {unknown} (가능: {list(_FALLBACK_SOURCES)})")
return [n for n in names if n in _FALLBACK_SOURCES]
def _build_worker(i: int, concurrency: int, has_openai: bool, neg_cache, history):
"""워커 1개의 자립 세트(브라우저 어댑터·AI·핸들러)를 만든다.
@ -52,12 +67,14 @@ def _build_worker(i: int, concurrency: int, has_openai: bool, neg_cache, history
"coupang": CoupangAdapter(headless=False, user_data_dir=_pf("coupang"), proxy=proxy, on_detect=bot_log.record),
"naver": NaverAdapter(), # httpx 직접(프록시 미경유) — 워커별 인스턴스(last_bytes 경합 회피)
}
# 폴백은 데드라인이 상한이라 봇감지 재시도(챌린지 대기 2배)를 끈다(max_block_retries=0) — 빠르게 포기·스킵.
fallback_adapters = {
"gmarket": EsmAdapter("gmarket", headless=False, user_data_dir=_pf("gmarket"), proxy=proxy, on_detect=bot_log.record, max_block_retries=0),
"auction": EsmAdapter("auction", headless=False, user_data_dir=_pf("auction"), proxy=proxy, on_detect=bot_log.record, max_block_retries=0),
"st11": ElevenStAdapter(headless=False, user_data_dir=_pf("st11"), proxy=proxy, on_detect=bot_log.record, max_block_retries=0),
}
# 폴백은 기본 비활성(LPS_FALLBACKS 로 켬 — 상단 주석 참고). 켤 땐 데드라인이 상한이라
# 봇감지 재시도(챌린지 대기 2배)를 끈다(max_block_retries=0) — 빠르게 포기·스킵.
fallback_adapters = {}
for name in _enabled_fallbacks():
if name == "st11":
fallback_adapters[name] = ElevenStAdapter(headless=False, user_data_dir=_pf("st11"), proxy=proxy, on_detect=bot_log.record, max_block_retries=0)
else:
fallback_adapters[name] = EsmAdapter(name, headless=False, user_data_dir=_pf(name), proxy=proxy, on_detect=bot_log.record, max_block_retries=0)
# AI 도 워커별 인스턴스 — 공유 상태(last_usage) 경합 원천 제거
judge = SimilarityJudge() if has_openai else None
keyword_gen = KeywordGenerator() if has_openai else None
@ -164,7 +181,9 @@ async def main(concurrency: int = 1):
egress_ip, egress_port = await probe.healthcheck()
LOG.i(f"DECODO 프리플라이트 OK — egress IP {egress_ip} (port {egress_port})") if egress_ip \
else LOG.w("DECODO 프리플라이트 실패 — 살아있는 포트를 못 찾음(런타임 회전으로 재시도)")
LOG.i(f"AI(판정+검색어생성): {'ON' if has_openai else 'OFF(키 없음)'} · 오픈마켓 폴백: gmarket, auction, st11")
fb = _enabled_fallbacks()
LOG.i(f"AI(판정+검색어생성): {'ON' if has_openai else 'OFF(키 없음)'} · "
f"오픈마켓 폴백: {', '.join(fb) if fb else 'OFF(기본 — LPS_FALLBACKS 로 활성화)'}")
stop = asyncio.Event()
listeners: list[JobListener] = []