docs(lps): 2026-08-05 세션 기록 — 변경 배경·결정 근거·미해결·다음 할 일

다음 세션이 이어받을 수 있게 남긴다.
- docs/2026-08-05-session-notes.md 신규: 왜 네이버가 크롤로 갔는지, 이번에 정한 것
  (추천순 유지·순위는 상품가·신뢰 신호·AI 배치 10건)과 그 근거, 미해결(컨테이너 크롤 차단),
  다음 할 일 5가지, 작업 시 주의(테스트가 실DB를 TRUNCATE 한다)
- architecture.md: IP 회전을 DB 장부·게이트웨이 2개·회전무효 차단 구분·AI 배치로 갱신
- database.md: proxy_port 테이블 추가(6종), price_history 의 신뢰·배송 컬럼 설명과
  '미검증 오퍼 비율' 쿼리
This commit is contained in:
민헌 2026-08-05 15:51:19 +09:00
parent b2b5708a6a
commit 042e2d3012
3 changed files with 121 additions and 2 deletions

View File

@ -0,0 +1,69 @@
# 2026-08-05 작업 기록 — 네이버 크롤 전환 · IP 장부 DB화 · AI 매칭 수리
다음 세션이 이어받을 수 있도록 **무엇이 왜 바뀌었고, 무엇이 남았는지**만 적는다.
설계 상세는 각 코드 파일 주석과 `architecture.md`·`operations.md` 를 본다.
## 1. 무슨 일이 있었나
네이버가 **쇼핑 검색 오픈API 를 2026-07-31 종료**했다(유예·대체 없음). `shop.json` 은 정상 키로도
404 `SE05` 만 돌려준다. 후속인 NCP 네이버 API 허브에도 **쇼핑 검색은 없다**(게이트웨이에서
`/search/v1/shop` = 404, 문서에도 없음). 즉 네이버 가격을 API 로 얻을 방법이 사라졌다.
→ 쿠팡과 같은 스택(patchright + 실제 Chrome)으로 **모바일 msearch 크롤 전환**.
## 2. 지금 상태
| 항목 | 상태 |
|---|---|
| 네이버 최저가 | 크롤로 복구. 검색당 40건(무한스크롤 상한) |
| 쿠팡 | 정상. 단 IP 평판에 따라 간헐적 차단(회전으로 회복) |
| IP 로테이션 | `proxy_port` DB 장부. 멀티 프로세스 안전 |
| 워커 실행 | **호스트 실행 권장** — 컨테이너는 크롤이 막힌다(아래 4번) |
| 테스트 | 237 passed |
## 3. 이번에 정한 것 (되돌리려면 근거부터 볼 것)
- **정렬은 추천순 유지**. 가격순(`sort=price_asc`)은 차단 없이 되지만 A/B(상품 8개)에서
최저가 갱신 **0건**, 오히려 2건 악화. 후보가 늘면 AI 매칭이 흔들린다.
- **순위는 상품가 기준**. 배송 주체(로켓/판매자로켓/네이버 판매자)가 다르면 배송비 비교가
무의미해서다. 대신 `final_shipping_fee/type/label` 로 **기록은 남긴다** — 나중에
"배송비를 더하면 순위가 뒤집히는 비율"을 데이터로 판단하기 위해.
- **신뢰 신호 수집**(`rating`/`review_count`): 리뷰·평점 없는 오퍼는 재고 없는 미끼가격일 수
있다. 최저가는 '가장 싼 값'이 아니라 '실제로 살 수 있는 가장 싼 값'이어야 한다.
NULL(정보 없음)과 0(리뷰 0개)은 다른 뜻이라 기본값을 두지 않았다.
- **AI 판정은 10건씩 쪼갠다**. 후보 37건을 일괄로 넣으면 gpt-4o-mini 가 전 항목에 같은
점수(70)를 매기고 전부 불일치로 답한다(3회 재현). 쪼개면 같은 모델로 12건 매칭.
## 4. 미해결 — 컨테이너에서 크롤이 막힌다
같은 코드·같은 공인 IP인데 호스트는 되고 컨테이너만 막힌다(네이버 405+캡차 / 쿠팡 403).
배제한 원인: 공인 IP·TLS 지문(JA4/H2 동일)·HTTP 헤더(HTTPS 에서 구조·순서 동일)·로케일·
WebGL·UA 스푸핑·리소스 라우팅. 상세와 배포 시 확인 순서는 `operations.md` 의
'컨테이너 크롤 차단' 절.
⚠️ 실측 환경이 Apple Silicon 맥이라 컨테이너가 amd64 를 Rosetta 로 에뮬레이션한다.
**"컨테이너는 안 된다"가 아니라 "이 맥의 컨테이너에서는 안 된다"** 로 읽어야 한다.
2026-07-09 에는 같은 컨테이너로 8몰 크롤이 통과한 이력이 있다. 배포 서버(실제 x86)에서
재검증이 필요하다.
기동 직후 `docker logs lps-worker | grep warmup` 으로 소스별 통과 여부가 바로 찍힌다
(웜업이 크롤 프리플라이트를 겸하고, 3회 실패하면 알림까지 나간다).
## 5. 다음에 할 일 (우선순위)
1. **AI 매칭 정확도 측정** — 이번엔 상품 3개로 '명백한 붕괴'만 고쳤다. 정답 세트 20~30개로
정확도를 재야 "몇 % 맞나"를 답할 수 있다. 지금 최저가 품질의 최대 병목이다.
2. **재정제 라운드 검증** — 테팔 사례(검색 결과에 진짜 '프라이팬 26cm'가 없어 매칭 0건)처럼
검색어가 결과를 좌우한다. 정밀→광역 재검색이 실제로 구제하는지 확인된 적 없다.
3. **쿠팡 광고·품절 필터** — 네이버는 광고를 걸러내는데 쿠팡은 안 한다(비대칭).
`custom-oos`(품절) 클래스도 파서가 쓰지 않는다. 품절 상품이 최저가로 잡히면
'살 수 없는 가격'이 된다.
4. **소스 결측 표시** — 쿠팡이 막혀 네이버 단독으로 나온 결과가 구분되지 않는다.
5. **카테고리 확대 검증** — 파싱 검증을 생활용품 위주로만 했다. 가전·의류·식품 스모크 필요.
## 6. 작업 시 주의
- **테스트가 실DB를 TRUNCATE 한다**(`bot_detection`·`price_history`·`job`·`ip_session`).
워커를 돌린 뒤 pytest 하면 관측 이력이 지워진다. 테스트 전 `pkill -f worker_main.py`.
- DDL 은 `migrations/` 에 날짜 파일로 남겼다(`2026-08-05-proxy_port.sql`,
`-price_history-trust.sql`, `-price_history-shipping.sql`). dev DB 에는 적용 완료.

View File

@ -79,7 +79,11 @@
| **네이버** | **WTM 캡차** (모바일 msearch. PC 는 405/418 로 아예 막힘) | 실제 Chrome + **한국 IP 필수**(kr.decodo.com) + `ko-KR` 로케일 + **리소스 차단 금지**(route 를 걸면 즉시 캡차 — 셋 중 하나만 빠져도 막힘). 해외 IP 는 회전 무효인 하드차단이라 즉시 실패시킨다 |
**핵심 메커니즘**
- **IP 회전(DECODO)**: 같은 IP로 계속 두드리면 차단 → 시간창 기반 sticky + 봇감지/전송오류 시 즉시 회전. 감지 이력(`bot_detection`)을 기록해 패턴 분석. **프록시 전송오류(407/터널)** 도 사이트 차단과 구분해 회전.
- **IP 회전(DECODO)**: 같은 IP로 계속 두드리면 차단 → 봇감지/전송오류 시 즉시 회전, 차단 전에도 **요청 예산**(쿠팡 3·네이버 10)에 도달하면 선제 회전. 감지 이력(`bot_detection`)으로 패턴 분석. **프록시 전송오류(407/터널)** 도 사이트 차단과 구분해 회전.
- **포트(IP 세션) 장부 = DB**(`proxy_port`): 한 계정을 **여러 프로세스**가 나눠 쓰므로 임대·휴식·쿨다운을 DB 에 둔다. 한 UPDATE 안에서 `FOR UPDATE SKIP LOCKED` 로 배타 임대(이중 배정 불가), 회전은 LRU(`last_used_at`), 죽은 프로세스는 `leased_until` 만료로 자동 회수. **휴식(선제 회전, 짧음) ≠ 쿨다운(차단, 30분)** — 휴식이 없으면 방금 놓은 IP 를 다른 워커가 곧바로 집어 예산이 무의미해진다.
- **게이트웨이 2개**: 쿠팡=`gate.decodo.com`(국가 무지정) / 네이버=`kr.decodo.com`(한국 전용 — 해외 IP 는 msearch 가 즉시 하드차단). 같은 포트 번호라도 게이트웨이가 다르면 IP 가 다르다 → 자원 키는 `(host, port)`.
- **회전 무효 차단 구분**(`fatal_block_markers`): IP 를 바꿔도 결과가 같은 구조적 차단은 포트를 태우지 않고 즉시 실패시키고 알린다. 태우면 원인도 못 고친 채 풀만 말린다.
- **AI 판정은 배치로 쪼갠다**(`_BATCH=10`): 후보를 한 번에 많이 넣으면 모델이 전 항목에 같은 점수를 매기고 전부 불일치로 답한다(실측 37건 → 0건 매칭). 정확도 문제지 속도 문제가 아니다.
- **선제 회전(요청 예산)**: IP당 요청 수가 예산(`[DecodoConfig].ip_request_budget`, 기본 3 — 실측상 5회 부근 차단)에 닿으면 **차단당하기 전에** 회전. 선제 교체된 포트는 평판이 깨끗해 로테이션 복귀 시 재사용됩니다. 반면 **차단 감지된 포트는 쿨다운**(`[DecodoConfig].port_cooldown_sec`, 기본 max(sticky, 30분)) 동안 격리 — sticky 만료 후 복귀라 사실상 새 IP. 세션마다 `ip_session`(요청 수·종료 사유)을 남겨 예산 상한을 데이터로 튜닝합니다(쿼리는 database.md).
- **시작 프리플라이트 + 웜업**: 기동 시 살아있는 프록시 포트를 선점(egress IP 로그)하고, 챌린지 소스를 미리 1회 풀어 **쿠키를 선점**(나쁜 IP는 회전 재시도) → 실 작업은 웜(빠름).
- **동적 리소스 차단**: 이미지·폰트 등을 차단해 대역폭↓. 단 **Turnstile은 리소스 차단을 봇 신호로 감지**하므로, ESM은 챌린지 solving 중(콜드)엔 차단을 풀고 **cf_clearance 확보 후(웜)에만 차단**합니다.

View File

@ -6,7 +6,7 @@
- **테이블 정의**: `common/database/model/models.py` (SQLAlchemy) — 이 파일이 스키마의 단일 출처
- **공통 규칙**: 외래키(FK) 안 씀(무결성은 앱에서) · 코드값은 정수(SMALLINT) · 시각은 전부 `TIMESTAMPTZ`(UTC)
## 테이블 5종 한눈에
## 테이블 6종 한눈에
| 테이블 | 용도 |
|--------|------|
@ -15,6 +15,7 @@
| `search_negative` | 네거티브 캐시 — "없음"으로 확인된 상품을 일정 시간 기억 |
| `bot_detection` | 봇 감지 이력 — 쿠팡이 차단한 패턴 기록 |
| `ip_session` | IP 세션 종료 이력 — 요청 예산(선제 회전) 상한 튜닝 데이터 |
| `proxy_port` | 프록시 포트(IP 세션) 임대 장부 — **프로세스 간 공유** 상태 |
---
@ -67,6 +68,26 @@
---
### 최저가 오퍼의 품질 정보 (2026-08-05 추가)
가격만으로는 '실제로 살 수 있는 값인지' 알 수 없어, 최종 최저가 오퍼의 근거를 함께 남긴다.
| 컬럼 | 뜻 |
|------|-----|
| `final_rating` / `final_review_count` | 평점·리뷰 수. **둘 다 NULL 이면 미검증 오퍼**(재고 없는 미끼가격일 수 있음). NULL(정보 없음)과 0(리뷰 0개)은 다른 뜻이라 기본값 없음 |
| `final_shipping_fee` | 0=무료, NULL=미확인(로켓처럼 조건부 무료라 화면에 금액이 없음) |
| `final_shipping_type` | free / paid / rocket / rocket_merchant |
| `final_shipping_label` | 화면 문구 원문(예: `내일(목) 도착 보장 · 와우는 무료배송 ∙ 무료반품 ∙ 새벽도착`) |
**순위는 상품가 기준이다.** 배송 주체가 다르면(쿠팡 로켓 / 판매자로켓 / 네이버 판매자) 배송비
비교가 무의미해서다 — 기록만 남겨 "배송비를 더하면 순위가 뒤집히는 비율"을 나중에 판단한다.
```sql
-- 리뷰·평점 없는 오퍼가 최저가로 잡힌 비율(유령상품 노출도)
SELECT count(*) FILTER (WHERE final_review_count IS NULL) * 100.0 / count(*) AS 미검증_퍼센트
FROM price_history WHERE outcome = 'found';
```
## 3. `search_negative` — 네거티브 캐시
"검색해도 없더라"를 일정 시간(기본 24h) 기억해 **재검색 낭비를 막습니다**.
@ -130,6 +151,31 @@ SELECT end_reason, count(*), avg(requests)::numeric(5,1) AS avg_req, min(request
---
## 6. `proxy_port` — 프록시 포트(IP 세션) 임대 장부
한 DECODO 계정을 **여러 워커 프로세스**가 나눠 쓰므로 임대 상태를 DB 에 둔다(인메모리면 서로의
임대·차단을 몰라 같은 IP 를 동시에 잡거나 태운 IP 를 곧바로 재사용한다).
| 컬럼 | 뜻 |
|------|-----|
| `host`, `port` | PK. 게이트웨이 + 포트 = sticky IP 세션 1개 (같은 번호라도 게이트웨이가 다르면 다른 IP) |
| `owner` | 현재 임대자(`소스-PID-워커`) |
| `leased_until` | 임대 만료(=sticky 수명). 프로세스가 죽어도 이 시각이 지나면 자동 회수 |
| `rest_until` | 휴식(선제 회전) 만료 — 탄 게 아니라 쉬는 것, 소진 시 가장 먼저 회수 |
| `cooldown_until` | 쿨다운(차단) 만료 |
| `last_used_at` | LRU 회전 기준 — 가장 오래 안 쓴 포트부터 배정 |
| `use_count`, `burn_count` | 누적 임대·차단(상습 불량 IP 슬롯 식별) |
```sql
-- 게이트웨이별 현황(고갈 점검)
SELECT host,
count(*) FILTER (WHERE leased_until > now()) AS 임대,
count(*) FILTER (WHERE rest_until > now()) AS 휴식,
count(*) FILTER (WHERE cooldown_until > now()) AS 쿨다운,
sum(burn_count) AS 누적차단
FROM proxy_port GROUP BY host;
```
## 스키마 생성/관리
- 개발·테스트: SQLAlchemy 모델에서 `create_all`로 자동 생성.