From 5c9567919f4d53a8bec7fb5ae3d040344141c0e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Wed, 5 Aug 2026 13:33:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(lps):=20=EB=84=A4=EC=9D=B4=EB=B2=84=20?= =?UTF-8?q?=ED=9A=8C=EC=88=98=EC=9C=A8=2048=E2=86=92120=EA=B1=B4(=EC=A0=81?= =?UTF-8?q?=EC=9D=91=ED=98=95=20=EC=8A=A4=ED=81=AC=EB=A1=A4)=20+=20?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EC=B0=A8=EB=8B=A8=EC=9D=84=20=EA=B8=B0?= =?UTF-8?q?=EB=8F=99=20=EC=A6=89=EC=8B=9C=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **회수율**: 베이스 _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. --- lps/docs/operations.md | 27 ++++++++++++ lps/services/search/naver_shop/adapter.py | 35 ++++++++++++++- lps/tests/test_alerts.py | 52 +++++++++++++++++++++++ lps/worker_main.py | 30 ++++++++++--- 4 files changed, 135 insertions(+), 9 deletions(-) diff --git a/lps/docs/operations.md b/lps/docs/operations.md index 9293a5e..704d644 100644 --- a/lps/docs/operations.md +++ b/lps/docs/operations.md @@ -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 배포 diff --git a/lps/services/search/naver_shop/adapter.py b/lps/services/search/naver_shop/adapter.py index eb67ade..d147d6c 100644 --- a/lps/services/search/naver_shop/adapter.py +++ b/lps/services/search/naver_shop/adapter.py @@ -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)) diff --git a/lps/tests/test_alerts.py b/lps/tests/test_alerts.py index 9fe27b8..459b562 100644 --- a/lps/tests/test_alerts.py +++ b/lps/tests/test_alerts.py @@ -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 == [] diff --git a/lps/worker_main.py b/lps/worker_main.py index db0c840..903f83d 100644 --- a/lps/worker_main.py +++ b/lps/worker_main.py @@ -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 후 하던 잡이 이 시간 안에 끝나면 자연 종료, 초과하면 강제 취소.