diff --git a/docker-compose.yml b/docker-compose.yml index d7d8585..bde179c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/lps/README.md b/lps/README.md index b2085eb..9008ff4 100644 --- a/lps/README.md +++ b/lps/README.md @@ -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) 최저가를 시계열로 기록 | diff --git a/lps/docs/architecture.md b/lps/docs/architecture.md index 79ecc6e..e61e6c5 100644 --- a/lps/docs/architecture.md +++ b/lps/docs/architecture.md @@ -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 포함) diff --git a/lps/docs/decision-openmarket-crawler.md b/lps/docs/decision-openmarket-crawler.md index ac44328..69975ad 100644 --- a/lps/docs/decision-openmarket-crawler.md +++ b/lps/docs/decision-openmarket-crawler.md @@ -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) → ③ 웜업/차단 로그 확인. 미사용 기간 동안 셀렉터는 낡는다고 가정할 것. diff --git a/lps/docs/operations.md b/lps/docs/operations.md index f57e6c1..4f43fb0 100644 --- a/lps/docs/operations.md +++ b/lps/docs/operations.md @@ -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 사용). diff --git a/lps/run_local_worker.sh b/lps/run_local_worker.sh index b5df08f..f670adc 100755 --- a/lps/run_local_worker.sh +++ b/lps/run_local_worker.sh @@ -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 "" diff --git a/lps/worker_main.py b/lps/worker_main.py index 6534b6f..ba3d805 100644 --- a/lps/worker_main.py +++ b/lps/worker_main.py @@ -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] = []