feat(lps): 네이버 회수율 48→120건(적응형 스크롤) + 크롤 차단을 기동 즉시 알림

**회수율**: 베이스 _wait_ready 는 '고정 3회 스크롤 → 셀렉터 대기' 순서라, 프록시 지연이 있으면
**아직 아무것도 안 그려진 화면을 스크롤**하고 끝났다. 네이버용으로 순서를 뒤집고 종료 조건을
횟수가 아니라 '카드 수가 더 안 늘어남'으로 바꿨다 — 네트워크가 느리든 빠르든 같은 결과가 나온다.
A/B(같은 IP·같은 세션, 3개 쿼리): 14·14·20 = 48건 → **40·40·40 = 120건**(전부 상한 도달).

**크롤 프리플라이트**: 웜업 대상에 naver 를 추가하고, 3회 모두 실패하면 로그가 아니라 **알림**을
쏜다. 컨테이너 워커는 크롤이 막혀도 하트비트가 살아 있어 healthy 로 보이고, 잡이 DEAD 로
쌓일 때까지 아무도 모른다(실측). 성공하면 해소 알림으로 자동 정리된다.
AlertManager 를 main 에서 만들어 웜업·ops 모니터가 쿨다운 상태를 공유한다.

**문서**: operations 에 차단 마커별 대응표(비정상적인 접근=구조적/wtm_captcha=회전)와
'컨테이너 크롤 차단' 절 추가 — 배제한 원인, Rosetta 에뮬 주의(= '이 맥에서만'일 수 있음),
배포 시 확인 순서(warmup 로그 → 호스트 비교 → 워커만 호스트 실행).

테스트 3건 추가(웜업 실패 알림·성공 해소·비크롤 소스 스킵), 전체 223 passed·0 failed.
This commit is contained in:
민헌 2026-08-05 13:33:19 +09:00
parent b7fc327779
commit 5c9567919f
4 changed files with 135 additions and 9 deletions

View File

@ -204,6 +204,33 @@ LPS_LIVE=1 python -m pytest tests/test_browser_base.py::test_live_smoke # 라
| AI 매칭이 0건 자주 발생 | 검색어 모호/스펙 불일치 → `product_name`/`specification`을 더 정확히 |
| 검색이 너무 느림/비쌈 | `result.metrics`로 소스별 시간·DECODO 바이트 확인. 대역폭이 대부분(오픈마켓 크롤) |
| `result.desc = LPS_JOB_NOT_FOUND` | 존재하지 않거나 잘못된 job_id |
| **네이버 `비정상적인 접근`(2.6KB)** | 해외 IP 로 접근한 것 — **IP 회전으로 회복 불가**. `[DecodoConfig].kr_host` 가 비었거나 오타. 구조적 차단이라 포트를 태우지 않고 즉시 실패하며 `fatal_block` 알림이 뜬다 |
| **네이버 `wtm_captcha`(47~63KB)** | IP 평판/세션 — 자동 IP 회전으로 회복. 반복되면 KR 풀 소모 상태(`proxy_port` 테이블) 확인 |
| **기동 직후 `[warmup:*] 3회 모두 실패`** | 그 소스가 **이 환경에서** 크롤 불가. 잡을 넣기 전에 환경부터 확인할 것 — 아래 '컨테이너 크롤 차단' 참고 |
| **워커는 healthy 인데 계속 0건** | 하트비트는 크롤 성공과 무관하다. `docker logs`에서 `[warmup:*]` 줄과 `BOT-DETECTED` 마커를 먼저 볼 것 |
### 컨테이너 크롤 차단 (2026-08-05 실측 · 미해결)
**증상**: 같은 코드·같은 공인 IP인데 호스트에서는 되고 컨테이너에서만 막힌다.
| | 호스트(macOS Chrome) | 컨테이너(Linux Chrome + Xvfb) |
|---|---|---|
| 네이버 msearch | 200 · 40건 | **405 + wtm_captcha** |
| 쿠팡 | 200 · 60건 | **403 Akamai(엣지)** |
배제한 원인: 공인 IP(동일)·TLS 지문(JA4·H2 해시 동일)·HTTP 헤더(HTTPS 에서 구조·순서 완전 동일,
차이는 `Accept-Language`/`Sec-Ch-Ua-Platform` 두 값뿐)·로케일(ko-KR 로 맞춰도 동일)·WebGL(SwiftShader 로
살려도 동일)·UA/플랫폼 스푸핑·리소스 라우팅.
**주의**: 실측 환경이 Apple Silicon 맥이라 컨테이너가 **amd64 를 Rosetta 로 에뮬레이션**한다.
실제 x86 리눅스 서버에서는 다를 수 있고, 2026-07-09 에는 같은 컨테이너로 8몰 크롤이 통과한 이력이 있다.
그러니 "컨테이너는 안 된다"가 아니라 **"이 맥의 컨테이너에서는 안 된다"** 로 읽어야 한다.
**배포 시 확인 순서**
1. `docker logs lps-worker | grep warmup` — 기동 직후 소스별 통과 여부가 찍힌다
2. 실패하면 같은 서버 호스트에서 `./run_local_worker.sh` 로 돌려 비교(호스트는 되는데 컨테이너만 막히는지)
3. 호스트만 된다면 당분간 **워커는 호스트 실행**, API·나머지는 컨테이너로 운영한다
(compose 에서 `--scale lps-worker=0` 으로 워커만 빼면 된다)
## 7. Docker 배포

View File

@ -52,8 +52,10 @@ class NaverShopAdapter(BrowserSearchAdapter):
min_result_html = _MIN_RESULT_HTML
ready_selector = SELECTORS.card
ready_timeout_ms = 20000
# 모바일은 무한스크롤 — 초기 20건, 스크롤하면 40건까지 늘고 그 이상은 안 나온다(실측).
scroll_steps = 3
# 무한스크롤은 _wait_ready 를 직접 구현해 처리한다(베이스의 고정 횟수 스크롤은 쓰지 않는다).
scroll_steps = 0
max_scrolls = 6 # 초기 20건 → 40건에서 멈춘다(실측). 여유 있게 6회면 충분
scroll_wait_ms = 1500 # 프록시 경유라 렌더가 느리다 — 한 번에 다 안 붙는다
# ⚠️ 리소스 차단을 **켜면 안 된다**. route 를 걸면 WTM 이 즉시 캡차로 넘긴다(실측 2026-08-04):
# 차단 없음 → 정상 14건 · 전송 3.07MB
# image/media/font 만 차단 → 차단 · 0.58MB ← CSS 를 살려도 안 통한다
@ -65,6 +67,35 @@ class NaverShopAdapter(BrowserSearchAdapter):
# (실측: 같은 KR 프록시·같은 브라우저에서 이 두 줄 유무로 캡차↔정상이 갈렸다).
context_options = {"locale": "ko-KR", "timezone_id": "Asia/Seoul"}
async def _wait_ready(self, page):
"""첫 카드를 기다린 뒤, 카드 수가 더 안 늘 때까지 바닥으로 스크롤한다.
베이스 구현은 '고정 횟수 스크롤 → 셀렉터 대기' 순서라 프록시 지연이 있으면
**아직 아무것도 안 그려진 화면을 스크롤**하고 끝난다(실측: 40건 나올 페이지에서 14건).
그래서 순서를 뒤집고, 횟수가 아니라 '더 안 늘어남'을 종료 조건으로 둔다 —
네트워크가 느리든 빠르든 같은 결과를 얻는다.
"""
try:
await page.wait_for_selector(self.ready_selector, timeout=self.ready_timeout_ms)
except Exception:
return # 카드가 아예 없음 → 차단 판정(마커/짧은HTML)에 맡긴다
count_js = f"() => document.querySelectorAll('{SELECTORS.card}').length"
prev = -1
for _ in range(self.max_scrolls):
try:
n = await page.evaluate(count_js)
except Exception:
return
if n == prev: # 스크롤해도 안 늘면 끝(더 기다릴 이유 없음)
return
prev = n
try:
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
except Exception:
return
await page.wait_for_timeout(self.scroll_wait_ms)
def _search_url(self, query: str, limit: int) -> str:
return _SEARCH_URL.format(q=quote(query))

View File

@ -149,3 +149,55 @@ async def test_port_pool_status_survives_db_error():
_store = _Broken()
assert await _port_pool_status([_Ad()]) == ({}, None)
# ---- 크롤 프리플라이트(웜업) 알림 ------------------------------------------
class _WarmAdapter(SearchAdapter):
"""웜업 검증용 — 항상 실패하거나 항상 성공하는 어댑터."""
def __init__(self, source, fail):
self.source, self._fail = source, fail
self.calls = 0
async def search(self, query, limit=40):
self.calls += 1
if self._fail:
raise RuntimeError("blocked")
return [NormalizedProduct(source=self.source, name="p", price=1)]
def _rotate_ip(self, *a, **k):
pass
async def test_warmup_alerts_when_source_cannot_crawl():
"""기동 직후 크롤이 막혔으면 잡이 DEAD 로 쌓일 때까지 기다리지 않고 바로 알린다.
(컨테이너에서만 막히는 사례 — 프로세스는 healthy 라 조용히 0건이 된다)"""
from worker_main import _warmup_worker
mgr, sent, _ = _mgr()
ad = _WarmAdapter("naver", fail=True)
await _warmup_worker([ad], tries=2, attempt_timeout=5, alerts=mgr)
assert ad.calls == 2 # 재시도까지 소진
assert len(sent) == 1 and "naver" in sent[0]
assert mgr.is_active("warmup_naver")
async def test_warmup_success_clears_the_alert():
from worker_main import _warmup_worker
mgr, sent, _ = _mgr()
await _warmup_worker([_WarmAdapter("coupang", fail=True)], tries=1, attempt_timeout=5, alerts=mgr)
assert mgr.is_active("warmup_coupang")
await _warmup_worker([_WarmAdapter("coupang", fail=False)], tries=1, attempt_timeout=5, alerts=mgr)
assert not mgr.is_active("warmup_coupang") # 회복 알림 후 해소
assert "해소" in sent[-1]
async def test_warmup_skips_non_crawl_sources():
from worker_main import _warmup_worker
mgr, sent, _ = _mgr()
ad = _WarmAdapter("someapi", fail=True) # 브라우저 소스가 아님 → 웜업 대상 아님
await _warmup_worker([ad], tries=2, attempt_timeout=5, alerts=mgr)
assert ad.calls == 0 and sent == []

View File

@ -111,25 +111,39 @@ def _build_worker(i: int, concurrency: int, has_openai: bool, neg_cache, history
return handler, list(adapters.values()) + list(fallback_adapters.values())
async def _warmup_worker(worker_adapters, tries: int = 3, attempt_timeout: float = 60.0):
"""워커의 챌린지 소스(Turnstile/Akamai)를 미리 풀어 쿠키(cf_clearance 등)를 확보한다.
# 웜업 대상 = 브라우저로 긁는 소스 전부. 챌린지 쿠키 선점이 목적이지만, 동시에
# **기동 직후 크롤 가능 여부를 확인하는 프리플라이트**이기도 하다 — 여기서 실패하면
# 그 소스는 이 환경에서 아예 못 긁는다는 뜻이라, 잡이 쌓여 DEAD 될 때까지 기다리지 않고 바로 알린다.
# (실측 배경: 컨테이너 워커는 쿠팡·네이버가 모두 차단되는데 프로세스는 healthy 라 조용히 0건이 된다)
_WARMUP_SOURCES = ("gmarket", "auction", "coupang", "naver")
async def _warmup_worker(worker_adapters, tries: int = 3, attempt_timeout: float = 60.0, alerts=None):
"""워커의 크롤 소스를 미리 한 번 긁어 (1) 챌린지 쿠키 확보 (2) 크롤 가능 여부 확인.
콜드 비용을 시작 시 몰아, 이후 실 작업은 웜(빠름). 백그라운드로 돌려 잡 처리를 막지 않는다.
나쁜 IP 는 인터랙티브 Turnstile 로 에스컬레이션되므로, 실패 시 **다른 IP 로 회전 재시도**한다.
시도당 타임아웃 필수 — 웜업은 search 중 어댑터 락을 쥐므로, 여기서 행하면 그 워커의
모든 실 검색이 락 대기로 함께 멈춘다(2026-07-10 부하테스트에서 15분 행 실측)."""
for ad in worker_adapters:
if ad.source not in ("gmarket", "auction", "coupang"):
if ad.source not in _WARMUP_SOURCES:
continue
for attempt in range(tries):
try:
await asyncio.wait_for(ad.search("생수", limit=1), timeout=attempt_timeout)
LOG.i(f"[warmup:{ad.source}] 챌린지 통과·쿠키 확보 (시도 {attempt + 1})")
LOG.i(f"[warmup:{ad.source}] 통과·쿠키 확보 (시도 {attempt + 1})")
if alerts is not None:
await alerts.check(f"warmup_{ad.source}", False, f"{ad.source} 크롤 정상")
break
except Exception as ex:
if attempt < tries - 1:
ad._rotate_ip(f"웜업 재시도({type(ex).__name__}) — 새 IP")
else:
LOG.w(f"[warmup:{ad.source}] {tries}회 실패(첫 잡에서 재시도): {type(ex).__name__}")
msg = (f"[warmup:{ad.source}] {tries}회 모두 실패({type(ex).__name__}: {str(ex)[:80]}) — "
f"이 환경에서 {ad.source} 크롤이 막혔을 수 있습니다. "
f"호스트에서는 되는데 컨테이너에서만 막히는 사례가 있으니 실행 환경을 확인하세요")
LOG.w(msg)
if alerts is not None:
await alerts.check(f"warmup_{ad.source}", True, msg)
def _fatal_markers(adapters) -> list[str]:
@ -306,6 +320,8 @@ async def main(concurrency: int = 1):
# 잡 1건 데드라인 — 정상 검색은 폴백 포함 수분 내 끝난다(실측 15~22s). 크롤 행 실측(15분) 대비 상한.
job_deadline = worker_config.job_deadline_sec
# 알림은 웜업과 ops 모니터가 공유한다 — 같은 룰이 양쪽에서 중복 발화하지 않도록.
alerts = AlertManager(origin="worker")
# 포트(=IP 세션) 장부는 DB 다 — 프로세스가 늘어도 모두 같은 장부를 본다.
# 기동 시 게이트웨이별 포트 행을 보장한다(이미 있으면 상태 유지, 덮지 않음).
store = PortLeaseStore()
@ -315,7 +331,7 @@ async def main(concurrency: int = 1):
for i in range(concurrency):
handler, worker_adapters = _build_worker(i, concurrency, has_openai, neg_cache, history, store=store)
all_adapters += worker_adapters
bg_tasks.append(asyncio.create_task(_warmup_worker(worker_adapters))) # 챌린지 쿠키 선점(백그라운드)
bg_tasks.append(asyncio.create_task(_warmup_worker(worker_adapters, alerts=alerts))) # 쿠키 선점 + 크롤 프리플라이트
listener = JobListener()
await listener.start()
listeners.append(listener)
@ -324,7 +340,7 @@ async def main(concurrency: int = 1):
tasks.append(asyncio.create_task(run_reaper(queue, stop)))
tasks.append(asyncio.create_task(run_browser_reaper(all_adapters, stop))) # 유휴 브라우저 정리
tasks.append(asyncio.create_task(run_ops_monitor(queue, BotDetectionLog(), stop, adapters=all_adapters))) # 하트비트 + 임계 알림
tasks.append(asyncio.create_task(run_ops_monitor(queue, BotDetectionLog(), stop, adapters=all_adapters, alerts=alerts))) # 하트비트 + 임계 알림
LOG.i(f"LPS 워커 {concurrency}개 + reaper + 브라우저정리 + ops모니터(하트비트/알림) 기동 (워커별 세트 · 상품 {concurrency}개 동시)")
# 종료 유예: stop 후 하던 잡이 이 시간 안에 끝나면 자연 종료, 초과하면 강제 취소.