운영 실측으로 범위가 좁혀졌다: 같은 컨테이너에서 **쿠팡은 60건 정상, 네이버만** 매 IP
첫 요청부터 wtm_captcha. kr_host 도 정상이다(ensure_ports 에 kr.decodo.com, 마커가 해외 IP
하드차단 '비정상적인 접근'(2.6KB)이 아니라 wtm_captcha(43~57KB)). IP·게이트웨이가 아니라
브라우저 지문이 남은 후보다.
**먼저 확인한 것 — 지문 위장은 이 스택에서 불가능하다(중요)**
WebGL 렌더러(컨테이너=SwiftShader)를 실기기 이름으로 덮으려 했으나 주입 경로가 둘 다 막혔다:
context.add_init_script() 무반응. 단순 마커(window.__M__) 주입조차 undefined 다.
patchright 가 무력화한다 — 이 API 는 CDP
addScriptToEvaluateOnNewDocument 로 구현되고 그 흔적이 탐지
신호라, 걷어내는 게 patchright 의 존재 이유다.
확장(MV3, world:MAIN, document_start) 로드는 되지만 값이 바뀌지 않았다.
→ 동작하지 않는 코드를 남기지 않는다. 스푸핑은 넣지 않고, 대신 **판정**만 한다.
재시도 방지를 위해 이 실측을 fingerprint.py docstring 에 남겼다.
**넣은 것**
- services/search/fingerprint.py: 지문 판정(항목·정상여부·관측값·왜 문제인가). 진단이 곧
'무엇을 고칠까'로 이어지도록 이유를 함께 낸다.
- net_meter 토글: 네이버에 계측용 CDP(Network.enable)를 붙이지 않는다(기본 off).
네이버는 '요청 가로채기 자체가 탐지 신호'라 리소스 차단을 끈 이력이 있는데 계측 CDP 는
그대로 붙고 있었다 — 같은 계열이다. [WorkerConfig].naver_net_meter 로 재빌드 없이 A/B
(config 는 마운트). 끄면 대역폭이 DOM 크기 근사로 떨어질 뿐 크롤엔 영향 없다.
쿠팡은 잘 통과하므로 기본값을 바꾸지 않는다.
- Dockerfile.worker: fonts-noto-cjk·fonts-liberation 추가(나눔 하나뿐이면 폰트 지문이 부자연스럽다).
- diag_naver.py 가 같은 판정 모듈을 쓰도록 정리.
통과 환경 기준값(맥 실측): 의심 항목 0개 · HTTP 200 · 6건 · 1.26MB.
테스트 6건 추가(통과 지문을 문제로 몰지 않는지·컨테이너 지문을 놓치지 않는지), 전체 262 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
139 lines
7.7 KiB
Python
139 lines
7.7 KiB
Python
from config.config_loader import ConfigModel
|
||
|
||
|
||
class WebServerConfig(ConfigModel):
|
||
server_name: str = ""
|
||
port: int = 0
|
||
process_count: int = 1
|
||
is_ssl: bool = False
|
||
is_test: bool = False
|
||
# CORS 허용 오리진(프론트). 비우면 CORS 미적용. 예: ["http://localhost:5173"]
|
||
cors_origins: list[str] = []
|
||
# API guard 키. 비우면 개방 모드(개발). 채우면 /v1 전체에 X-API-Key 검증(prod).
|
||
# 여러 개 등록 가능 — 무중단 키 교체(새 키 추가 → 호출자 전환 → 옛 키 제거).
|
||
api_keys: list[str] = []
|
||
|
||
|
||
class LogConfig(ConfigModel):
|
||
print_console: bool = True
|
||
log_level: str = "debug"
|
||
|
||
|
||
# DB Read/Write 분리 설정.
|
||
# 하나의 논리 DB 에 대해 write(주) / read(복제) 접속 정보를 각각 가진다.
|
||
class MainDBConfig(ConfigModel):
|
||
db_type: str = "postgresql"
|
||
name: str = ""
|
||
write_host: str = ""
|
||
write_port: int = 5432
|
||
write_id: str = ""
|
||
write_pw: str = ""
|
||
read_host: str = ""
|
||
read_port: int = 5432
|
||
read_id: str = ""
|
||
read_pw: str = ""
|
||
show_log: bool = False
|
||
# 커넥션 풀 사이징. 실제 동시 커넥션 = (pool_size + max_overflow) x 엔진수(R/W=2) x process_count.
|
||
pool_size: int = 10
|
||
max_overflow: int = 20
|
||
# 커넥션 예산(자동 산정). >0 이면 process_count 에 맞춰 pool_size/max_overflow 를 자동 계산한다:
|
||
# (pool+overflow)x2xprocess_count ≤ connection_budget 가 되도록. (이때 위 pool_size/max_overflow 는 무시)
|
||
# PG max_connections·공유 DB 동거 서비스(worker·negosium 등)를 고려한 'lps API 가 쓸 총 커넥션 상한'.
|
||
# 0 이면 자동 산정 끔(위 pool_size/max_overflow 그대로 사용). env DB_CONNECTION_BUDGET 로 override.
|
||
connection_budget: int = 40
|
||
# SSL/TLS 모드: ""/"disable"=미사용(로컬), "require"/"verify-ca"/"verify-full"=관리형 DB(RDS/Aurora/Azure).
|
||
sslmode: str = ""
|
||
|
||
|
||
# ── 시크릿(API 키 등)도 TOML 로 통합 관리. config.local.toml 은 미커밋(*.toml). ──
|
||
# 배포는 이 파일을 마운트하거나(권장), 환경별로 바뀌는 값만 env override 한다(DB_HOST 등).
|
||
|
||
|
||
class NaverApiHubConfig(ConfigModel):
|
||
"""NCP NAVER API HUB(검색·검색어 트렌드·쇼핑 인사이트). 개발자센터 오픈API 의 후속.
|
||
|
||
옛 오픈API 와 인증 방식이 다르다 — X-Naver-Client-Id/Secret 이 아니라
|
||
X-NCP-APIGW-API-KEY-ID / X-NCP-APIGW-API-KEY 헤더를 쓴다(발급처도 NCP 콘솔).
|
||
client_id/secret 이 다 차야 활성(enabled).
|
||
"""
|
||
|
||
base_url: str = "https://naverapihub.apigw.ntruss.com"
|
||
client_id: str = ""
|
||
client_secret: str = ""
|
||
timeout_sec: float = 10.0
|
||
|
||
@property
|
||
def enabled(self) -> bool:
|
||
return bool(self.base_url and self.client_id and self.client_secret)
|
||
|
||
|
||
class OpenAIConfig(ConfigModel):
|
||
"""AI 유사도 판정/검색어 생성용 OpenAI."""
|
||
|
||
api_key: str = ""
|
||
model: str = "gpt-4o-mini"
|
||
|
||
|
||
class DecodoConfig(ConfigModel):
|
||
"""DECODO residential 프록시(쿠팡 전용, 포트기반 sticky). 값이 다 차야 활성."""
|
||
|
||
host: str = ""
|
||
# 한국 residential 타깃 게이트웨이(kr.decodo.com). 네이버는 해외 IP 를 즉시 하드차단하므로
|
||
# 국내 사이트 전용으로 쓴다(실측 2026-08-04: 해외 IP=2.6KB 차단페이지, KR IP=정상).
|
||
# 비우면 host 를 그대로 쓴다(=국가 무지정 월드와이드).
|
||
kr_host: str = ""
|
||
username: str = ""
|
||
password: str = ""
|
||
port_start: int = 0
|
||
port_end: int = 0
|
||
session_minutes: int = 10
|
||
cost_per_gb: float = 0.0 # DECODO residential 요금($/GB) — 검색 원가의 대역폭 비용 산정용(플랜에 맞게 설정)
|
||
# IP(포트 세션)당 요청 예산 — 도달 시 차단당하기 전에 선제 회전(평판 보존). 0=비활성.
|
||
# 쿠팡(Akamai) 실측상 5회 부근 차단 이력 → 보수적 3. 튜닝은 ip_session 분석(docs/database.md).
|
||
ip_request_budget: int = 3
|
||
# 네이버는 같은 KR IP 로 **12회 연속 검색까지 무차단**(2026-08-05 실측, IP 4개 전부 한계 미도달).
|
||
# 쿠팡 기준 3을 그대로 쓰면 불필요하게 4배 자주 회전해 KR 풀만 빨리 소모하고 지연도 늘어난다
|
||
# (회전 1회 = 브라우저 재기동 ~20s). 실측 12에 여유를 둔 10. 0=비활성(시간창 회전만).
|
||
naver_ip_request_budget: int = 10
|
||
# 차단 감지된 포트 격리 시간(초). 0=자동(max(sticky, 30분)) — sticky 만료 후 복귀라 사실상 새 IP.
|
||
port_cooldown_sec: int = 0
|
||
# 선제 회전(예산 도달)으로 놓은 포트의 휴식 시간(초). 0=자동(sticky 수명).
|
||
# 차단이 아니라 '쉬게 하는' 것이라 쿨다운보다 짧다 — 그래도 곧바로 재사용되면 안 된다.
|
||
port_rest_sec: int = 0
|
||
|
||
|
||
class WorkerConfig(ConfigModel):
|
||
"""워커 런타임 설정. (동시성만 실행 시 WORKER_CONCURRENCY env 로 임시 override 가능 — 대화형 스크립트용)"""
|
||
|
||
concurrency: int = 1 # 상품 동시 검색 수(워커별 브라우저 세트, Chrome 최대 4×N). 로컬 권장 2~3
|
||
fallbacks: list[str] = [] # 오픈마켓 폴백(기본 비활성). 예: ["gmarket", "auction", "st11"] — 켜기 전 라이브 스모크
|
||
profile_dir: str = "/tmp" # Chrome 프로필 베이스 경로. 영속 볼륨이면 재시작에도 cf_clearance 유지(재웜업 회피)
|
||
job_deadline_sec: float = 300 # 잡 1건 처리 상한(크롤 행 방어). 0=무제한(테스트용)
|
||
shutdown_grace_sec: float = 60 # graceful 종료 유예 — docker stop_grace_period 를 이보다 길게
|
||
chrome_channel: str = "chrome" # 로컬: 실제 Chrome 채널
|
||
chrome_executable: str = "" # 컨테이너: 시스템 chromium 경로(설정 시 channel 무시, --no-sandbox 적용)
|
||
heartbeat_file: str = "/tmp/lps_worker_heartbeat" # 하트비트 파일(Docker HEALTHCHECK 신선도 확인)
|
||
# ── 네이버 WTM 컨테이너 차단 대응(2026-08-06) ──────────────────────────────
|
||
# 같은 컨테이너에서 쿠팡은 정상인데 네이버만 매 IP 첫 요청부터 wtm_captcha 로 막힌다.
|
||
# 네이버에 바이트 계측 CDP(Network.enable)를 붙일지 — 네이버는 '요청 가로채기 자체가 탐지
|
||
# 신호'라 리소스 차단을 끈 이력이 있는데 계측 CDP 는 그대로 붙고 있었다. 끄면 대역폭 수치가
|
||
# DOM 크기 근사로 떨어질 뿐 크롤 동작엔 영향이 없다.
|
||
# config 는 마운트라 **재빌드 없이** 이 값만 바꿔 재시작하면 A/B 가 된다.
|
||
naver_net_meter: bool = False
|
||
|
||
|
||
class AlertConfig(ConfigModel):
|
||
"""임계 알림(AlertManager) 설정 — 룰 의미는 docs/operations.md 표 참고."""
|
||
|
||
webhook: str = "" # Slack 호환 웹훅 URL. 비우면 로그로만 알림
|
||
cooldown_min: int = 30 # 같은 룰 재발송 억제 시간(분). 해소 알림은 즉시
|
||
dead_1h: int = 20 # 최근 1h DEAD 잡 수 임계
|
||
blocks_1h: int = 80 # 최근 1h 봇 감지 수 임계
|
||
queue_lag_sec: int = 300 # 가장 오래된 PENDING 대기 초 임계
|
||
pool_pct: int = 90 # DB 커넥션 풀 포화율(%) 임계
|
||
source_fail_30m: int = 5 # 소스별 30분 내 시도 N회 이상 & 성공 0건
|
||
deadline_1h: int = 5 # 최근 1h 잡 데드라인 강제종료 수 임계
|
||
cost_1h_usd: float = 1.0 # 최근 1h 검색원가 합($) 임계
|
||
ports_low_pct: int = 30 # 가용 프록시 포트 비율(%) 임계
|
||
block_sessions_6h: int = 1 # 최근 6h '예산 회전에도 차단된' IP 세션 수 임계
|