# task-3037 — 네이버 상위노출 분석 회장 피드백 4건 반영

- **레벨**: Lv.2 · **팀**: dev3-team (다그다) · **저장소**: `Jeon-Jonghyuk/InsuRo`
- **브랜치**: `task/task-3037-dev3` · **base**: `2f6f48e` (origin/main)
- **범위**: PR 생성까지. **머지는 ANU 가 한다. 자동 머지 없음.**

---

## S — 상황

task-3025 로 네이버 블로그·카페 상위노출 분석을 배포(main `2f6f48e`)한 뒤, 회장이 직접 테스트하고 피드백 4건을 주었다.

1. 키워드 카운팅을 **제목 / 본문 분리**해서 제시
2. 이미지 개수를 **검색결과 리스트의 배지 숫자**와 일치시킬 것 (최우선)
3. 근거 글 목록이 광고 제외 Top10 인지 더블체크 + **순위 표기 정정** (1위 글이 `rank=4` 로 보였음)
4. 평균·중앙값 폐기 → **Top 3 기준** 산출, 화면에 "Top 3" 명시

## C — 복잡성

- ②의 배지가 **어느 DOM 요소에 있는지 아무도 몰랐다.** 명세는 "추측 금지, 실제 HTML 을 받아 확인하라"고 요구했다.
- ③은 기존 테스트가 **정반대 계약을 명시적으로 봉인**하고 있었다. `test_blog_rank_not_renumbered_after_ad_removed` 가 `rank == [1,3,4]` 를 단언하며 주석에 "1,2,3 으로 재부여됐다면 원본 노출 순위 보존 요구사항 위반"이라고 적혀 있었다. 즉 회장이 이번에 고치라고 한 그 동작이 이전 요구사항이었다.
- 본문 크롤링이 10건에 2~3분 걸려 검증 반복이 느렸다.

## Q — 핵심 질문

배지 숫자를 실제로 뽑아낼 수 있는가, 그리고 그 값이 회장이 화면에서 본 **5·8·3·4** 와 일치하는가?

## A — 답변 (결과)

**일치한다.** 실측 확인 후 4건 전부 반영하고 PR 을 열었다.

### ② 이미지 개수 = 배지 숫자 (최우선) — 달성

실제 네이버 HTML(`search.naver.com` 블로그탭, "대장암 초기증상")을 받아 조사한 결과 배지는 **두 가지 마크업 패턴**으로 존재한다.

- **패턴 A** — `data-sds-comp="ThumbnailTextBadge"` (썸네일 1장 레이아웃의 우하단 오버레이 원형 배지). 속성값이 해시가 아닌 시맨틱 문자열이라 안정적.
- **패턴 B** — 다중 썸네일 레이아웃에서 마지막 썸네일 `<a>` 뒤 형제 `<span>` 의 숫자. 클래스명(`ayJTn7u9XU7wkhwh`)은 빌드 해시라 불안정하므로 **구조 규칙**(RectangleImage 를 품은 `<a>` 들의 마지막 형제 span 이 순수 숫자)으로 파싱.

실측 근거(출현 횟수, 1-line HTML 이라 `grep -o | wc -l` 로 계수):

| HTML | ThumbnailTextBadge | image-after-content |
|---|---|---|
| blog.html (대장암 초기증상) | 20 | 56 |
| blog_silbo.html (실비보험) | 30 | 55 |
| cafe.html (대장암 초기증상) | **0** | **0** |

**회장 스크린샷 대조 — 실 HTTP 응답 기준 (광고 2건 제외 후 상위 4건)**

| rank | raw_position | 제목(앞 20자) | 산출 image_count | 회장 관측 배지 | 일치 |
|---|---|---|---|---|---|
| 1 | 3 | 대장암 초기증상, 놓치기 쉬운 배변 | 5 | 5 | O |
| 2 | 4 | "치질인 줄 알았는데" 전문의가 경고 | 8 | 8 | O |
| 3 | 5 | 대장암 초기증상은?대장암이 생기는 | 3 | 3 | O |
| 4 | 6 | 대장암 초기증상, 검진 연령부터 확인 | 4 | 4 | O |

**배지 방식 vs 기존 본문 파싱 방식 대조 (명세 요구 대조표)**

| rank | 배지(신) | 본문파싱(구) | 차이 |
|---|---|---|---|
| 1 | 5 | 14 | −9 |
| 2 | 8 | 16 | −8 |
| 3 | 3 | 7 | −4 |
| 4 | 4 | 15 | −11 |
| 5 | 9 | 22 | −13 |
| 6 | 6 | 18 | −12 |
| 7 | 5 | 12 | −7 |
| 8 | null | 3 | 대조불가 |
| 9 | 15 | 33 | −18 |
| 10 | null | 3 | 대조불가 |

**왜 다른가 — 추정이 아니라 실측으로 규명했다.** rank 3(배지 3, 본문파싱 7)을 해부한 결과:

- 같은 사진 1장이 **2번 계수**된다. SE3 `data-linkdata` 의 src 와 `<img>` 의 src 가 **`?type=w80_blur` 쿼리 유무로 문자열이 달라** `seen` 집합 dedup 이 실패한다. (쿼리 제거 시 동일 = True, 문자열 그대로 동일 = False 로 확인)
- 여기에 **블로거 프로필 썸네일**(`blogpfthumb-phinf.pstatic.net`) 1장이 본문 이미지로 섞여 들어간다.
- 검산: 실사진 3장 × 2 + 프로필 1 = **7** = 본문파싱 총계. 정확히 일치.

즉 기존 값이 2~3배 부풀어 있었던 것이며, **배지가 정답이다.** 회장 지적이 옳았다.

**배지 없는 글의 처리 규칙**: `image_count = null`(0 아님). 근거 — 네이버는 썸네일이 1장 이하인 글에서 `<span class="sds-comps-image-after-content"></span>` 를 **빈 채로** 두고 배지 div 를 생략한다. "0"이라는 텍스트가 들어가는 경우는 관측되지 않았다. 다만 DOM 만으로는 "원래 배지 없는 글"과 "마크업 변경/차단으로 파싱 실패"를 구분할 수 없으므로, **조용히 0 으로 채우지 않고** `errors` 에 미검출 건수와 "구분 불가"라는 사실 자체를 명시한다. 화면에도 노란 경고 배너로 노출된다.

### ① 키워드 카운팅 제목/본문 분리 — 달성

`title_keyword_count`(제목, 본문 크롤링 불필요 → 10건 전부 산출) / `body_keyword_count`(본문, Top3 만) 로 분리. 기존 `keyword_count` 는 제거. 화면에 "제목 키워드 목표"·"본문 키워드 목표" **별도 카드 2개**로 표시.

### ③ 순위 표기 정정 — 달성

- `rank` = **광고 제외 후 실제 순위**(1,2,3…) / `raw_position` = 광고 포함 네이버 원본 위치. 블로그·카페 양쪽 적용.
- 광고 제외가 Top10 산정 **전**에 이뤄지는지 독립 확인: 실측에서 raw 1·2 가 광고로 제외되고 rank 1~10 ↔ raw 3~12 로 **구멍 없이** 연속. ANU 선확인 내용이 맞았다.
- 화면은 큰 숫자로 `rank`, 그 아래 작게 "네이버 노출 N위"를 병기한다.

### ④ Top 3 기준 산출 — 달성

- 근거 글 목록은 **광고 제외 Top 10 유지**, 수치 산출은 **Top 3 만**.
- `Stats` 를 `{top1, min, max}` 로 교체. `median`/`p25`/`p75` **제거**(표본 3건에서 무의미 — 회장 지시). `top1` 은 정렬 최솟값이 아니라 **rank 1 글의 실제 값**이다.
- 응답에 `stats_basis="top3"`, `stats_sample_size`, `body_sample_size` 추가.
- 화면 "상위노출 목표치" 옆에 **"Top 3 기준" 뱃지**, 근거 글 목록 헤더에 "광고 제외 Top 10" 병기, Top3 글에는 "Top 3" 뱃지.
- **부수 효과: 본문 크롤링이 10건 → 3건으로 줄어 실 응답이 2~3분 → 3.3초.**

---

## ★ ANU 명세 전제 반증 3건

1. **"배지 방식이면 본문을 크롤링하지 않아도 되어 훨씬 빠르다"** → **부분적으로만 참.** 글자수와 본문 키워드 수는 여전히 본문이 필요하다. 실제 속도 개선의 원천은 ②가 아니라 **④(Top3 만 본문 수집)** 다. ②만 했다면 여전히 10건을 크롤링해야 했다.
2. **카페탭에는 배지가 존재하지 않는다.** `ThumbnailTextBadge` 0건, `sds-comps-image-after-content` 래퍼 자체가 0건(두 지표 모두). 카페는 배지 기능이 미구현이다. → 카페는 **기존 본문 파싱을 유지**했고, 응답에 `image_count_source`("badge"|"body"|null)를 실어 사용자가 구분할 수 있게 했다. **즉 "배지 숫자 일치"는 블로그에서만 성립하며 카페는 구조적으로 불가능하다.**
3. **③은 기존 테스트가 정반대로 봉인하고 있었다.** 명세는 "순위 표기 정정"만 지시했으나, 실제로는 task-3025 가 "네이버 원본 순위 보존"을 요구사항으로 삼아 테스트로 못박아 둔 상태였다. 회장 지시가 우선이므로 계약을 반전하되, **원래 보호하던 성질은 `raw_position` 단언으로 이전**해 잃지 않게 했다(삭제하지 않음).

## ★ 판단이 필요했던 지점

**MIN_SAMPLE_SIZE 3 → 1 로 하향.** Top3 만 보게 되면서 기존 임계값 3은 "Top3 전원 본문 수집 성공"을 강제한다. 카페는 비공개(로그인 필요) 글이 흔해 401 이 자주 발생하며(실측에서도 카페 rank3 이 즉시 401), 기존 기준이었다면 이 사례가 통째로 분석불가로 떨어진다. 회장이 "1위 글만 벤치마킹"이라고 명시했으므로 1건이라도 확보되면 분석 가치가 있다고 판단했다. 억지 숫자 방지 원칙은 유지된다 — rank1 값이 없으면 해당 metric 의 `Stats` 가 개별 `None` 이 되고 화면에 "—/산출 불가"로 표시된다.

**모리건 지적 (판단 보류, ANU 판단 요청)**: MIN_SAMPLE_SIZE=1 이 되면서 "표본은 있으나 부족" 중간 상태가 사실상 사라졌다(0건이 아니면 analyzable=True). 의도된 완화이나 원래의 표본부족 가드가 유명무실해졌을 가능성이 있다.

## ★ 범위 밖 관찰 (수정하지 않음, ANU 판단)

- `collector._parse_blog_item` 이 `.nblg` 앵커를 `.find()`(첫 매치)로 찾는데, 실측 DOM 에는 `.nblg` 가 글당 2~3개 존재한다(썸네일 링크·제목 링크 등). 현재는 제목 앵커가 DOM 상 먼저 나와 우연히 정상 동작하지만, 네이버가 마크업 순서를 바꾸면 제목 추출이 조용히 깨질 수 있다. task-3025 부터 있던 구조이며 이번 범위 밖이라 손대지 않았다.
- E2E 화면 우상단 플랜 배지가 "무료"로 표시된다. E2E 하네스가 `useUserPlan` 이 읽는 경로만 가로챘고 다른 컴포넌트는 별도 경로로 플랜을 읽기 때문으로 보인다. **테스트 하네스 아티팩트일 가능성이 높아 제품 결함으로 단정하지 않는다.** `NaverBenchmarkPanel` 게이팅·렌더링에는 영향 없음.

---

## L1 스모크테스트

- **서버 재시작**: 성공. 워크트리 코드로 `uvicorn main:app` 을 **별도 포트 8099** 에 기동(프로덕션 `insuro-api` 유닛은 건드리지 않음). `Application startup complete` 확인, `/openapi.json` 에 `/api/v1/naver-benchmark/analyze` 마운트 확인.
- **API 응답 확인**: curl 실측.
  - 플랜 게이트 생존: 무인증 `HTTP 401 {"detail":"Missing or invalid authorization"}`, 잘못된 토큰 `HTTP 401 {"detail":"Invalid token"}`
  - 데이터 경로: 게이트 없는 하네스 앱(포트 8098, `/tmp` 에만 생성, 리포 무수정)으로 실 네이버 대상 3회 호출 전부 `HTTP 200`
    - blog / 대장암 초기증상 — **3.3초**, 배지 5·8·3·4 일치, rank 1~10 ↔ raw 3~12
    - cafe / 대장암 초기증상 — 2.96초, `image_count_source="body"`, rank3 은 401 로 표본 제외되고 errors 에 기록
    - blog / 실비보험 갈아타기 — 3.38초, 배지 3·4·9
  - 구 계약 필드 제거 정확 키 대조: `median`/`p25`/`p75`/`sample_size`/`keyword_count` **전부 부재** 확인 (최초 부분문자열 검사에서 오탐이 나 정확 키 비교로 재확인)
- **스크린샷**: `/tmp/task3037-e2e/03-result-clean.png` (외 `01-initial.png`, `02-result.png`)
  - **팀장이 직접 이미지를 열어 육안 확인했다. 로그인월이 아니라 실제 결과 화면이다.**
  - 확인된 항목: "Top 3 기준" 뱃지 / 4카드(글자수 2,480자 · 이미지 수 5장 · **제목 키워드 1회** · **본문 키워드 14회**) / 근거 글 목록 10건 "광고 제외 Top 10" / 1위 글이 "1" + "네이버 노출 3위" 병기 / Top3 뱃지 / null 은 "—" 표시 / 배지 미검출 경고 배너
  - ★ 이 화면은 **픽스처 응답 기반**이라 *렌더링* 증거다. *데이터* 증거는 위의 실 HTTP 호출이다. 둘을 구분해 보고한다.

---

## 봉인(변이) 검증

명세 검증 7번. 변이가 no-op 이 아님을 먼저 증명한 뒤 FAIL 을 확인하고 완전 복원했다.

**변이 1 — Top10 전체로 되돌림** (`analyzer.py`, `items[:STATS_SAMPLE_TOP_N]` → `items`)
- no-op 아님 증명: `stats_sample_size` 3 → **10**, `body_sample_size` → 10
- 결과: `TestTop3StatsBasis`, `TestInsufficientSample` **2 failed** (`stats_sample_size 는 Top3 fetch 시도 건수(3)여야 하는데 5`)
- 복원 후 3 passed

**변이 2 — 광고 포함** (`collector.py`, `_is_ad_blog_item` 에 `return False` 강제)
- no-op 아님 증명: `ad_excluded` 1 → **0**, `collected` 3 → **4**, 광고 URL 이 sources 에 등장(True)
- 결과: `TestAdExclusion`, `TestRankPreservation`, `TestRankCorrectionFullPipeline` **3 failed**
- 복원 후 전체 26 passed

**복원 확인**: `git status --short` 에 구현 파일 미출현, 변이 잔재 grep 0건, `_is_ad_blog_item` 원문 육안 대조 완료.

---

## 회귀 (base 재측정 기준선 대조)

| 스위트 | base(`2f6f48e`) 실측 | 최종 | 판정 |
|---|---|---|---|
| pytest | 5 failed, 2837 passed, 3 skipped | 5 failed, **2847** passed, 3 skipped | 실패 증가 0, +10 통과 |
| vitest | 1 failed, 1463 passed (1464) | 1 failed, **1469** passed (1470) | 실패 증가 0, +6 통과 |

선재 실패는 base 와 **동일 집합**이며 본 작업과 무관하다.
- pytest: `scripts/tests/test_standby_fallback.py` 4건 + `tests/test_consultation_history_get.py::test_cors_fail_closed_when_ext_origin_unset` 1건 (worktree `.env` 오염으로 인한 알려진 함정)
- vitest: `src/lib/__tests__/push-utils.test.ts` 1건 (푸시 알림 유틸, 단독 재실행으로 동일 확인)

기준선은 명세 수치가 아니라 **base sha 워크트리에서 직접 재측정**했다.

---

## 수정 파일별 검증 상태

| 파일 | 변경 | 검증 |
|---|---|---|
| server/naver_benchmark/constants.py | +61 | 배지 셀렉터 상수 3종, STATS_SAMPLE_TOP_N=3, MIN_SAMPLE_SIZE 3→1(근거 주석) — import 성공, 실호출 반영 확인 |
| server/naver_benchmark/collector.py | +128 | 배지 파싱 패턴 A/B, rank·raw_position 분리 — 실 HTML 로 5·8·3·4 재현, 변이2 FAIL 확인 |
| server/naver_benchmark/analyzer.py | +226 | Stats top1/min/max, 제목·본문 키워드 분리, Top3 한정 — 실 HTTP 200, 변이1 FAIL 확인 |
| server/routes/naver_benchmark_v1.py | +5/−4 | docstring 신계약 갱신(로직 무변경) — 라우트 마운트 및 401 게이트 확인 |
| server/tests/test_naver_benchmark_task3025.py | +202 | 기존 7건 신계약 반영, raw_position 단언 추가 — 17 passed |
| server/tests/test_naver_benchmark_task3037.py | +508 (신규) | 배지/키워드분리/rank/Top3/카페배지부재 — 9 passed |
| src/components/keyword/NaverBenchmarkPanel.tsx | +171 | Top 3 표기·4카드·순위 병기·null "—" — tsc 0건, 실브라우저 육안 확인 |
| src/components/keyword/__tests__/NaverBenchmarkPanel.test.tsx | +376 | 신규 6건 포함 — 12 passed |
| memory/reports/task-3037.md | +16 | 착수 플레이스홀더(본 보고서로 대체) |

## 3 Step Why

- **1st Why** — 왜 이미지 개수가 회장 화면과 달랐나? 본문 `<img>`+`data-linkdata` 를 세는데, 같은 사진이 쿼리스트링 차이로 dedup 을 빠져나가 2번 계수되고 블로거 프로필 썸네일까지 섞였다. 실사진 3장이 7로 부풀었다.
- **2nd Why** — 왜 그 구현이 통과했나? task-3025 의 테스트가 **개수 계산 로직 자체**만 검증했고, "네이버가 화면에 보여주는 숫자"라는 외부 기준과 대조하지 않았다. 정답지가 없는 상태로 봉인된 것이다.
- **3rd Why** — 왜 정답지가 없었나? 배지의 존재를 몰랐다. 이번엔 명세가 "추측 금지, 실제 HTML 을 받아 확인하라"고 강제했고, 그 결과 회장 관측값 5·8·3·4 라는 **외부 정답지와의 대조표**가 처음으로 만들어졌다. 앞으로 이 대조가 봉인 테스트로 남는다.

## trip-wire 5종 실측

| 항목 | 실측 | 판정 |
|---|---|---|
| Critical7 | 0 | PASS |
| PII net-new | 0 | PASS |
| 회귀 실패 | 0 (기준선 대비 증가분) | PASS |
| forbidden_paths 침범 | 0 (main.py·requirements.txt·scripts·workflows 무변경) | PASS |
| nonce = task_id | task-3037 일치 | PASS |

## 모델 사용 기록

| 팀원 | 역할 | 모델 | 비고 |
|---|---|---|---|
| 루 (Lugh) | 백엔드 조사 + 구현 | sonnet | 배지 HTML 실측 조사 및 백엔드 전체 |
| 브리짓 (Brigid) | 프론트엔드 + 실브라우저 E2E | sonnet | 패널 개편, Playwright 검증 |
| 모리건 (Morrigan) | 테스트/QA | sonnet | 프론트/서버 테스트, 봉인 변이 실험 |
| 다그다 (Dagda) | 팀장 | opus | 설계·분배·검토·독립 검증(배지 대조·원인 규명·회귀·스크린샷 육안 확인) |

haiku 미사용. 아네(UX/UI)는 미활성화 — 기존 카드 레이아웃의 확장(3카드→4카드) + 문구 추가 수준이라 별도 UX 설계가 불필요하다고 판단했다.

## 완료 조건 (DoD) 대조

| DoD | 상태 |
|---|---|
| 이미지 개수가 네이버 검색결과 배지 숫자와 일치 | 달성(블로그). 카페는 배지 부재로 구조적 불가 — 반증 기록 |
| 키워드 수가 제목·본문으로 분리 제시 | 달성 |
| 근거 글은 광고 제외 Top10 이 1위부터 표기 | 달성 |
| 수치는 Top3 기준으로만 산출 + 화면 "Top 3" 명시 | 달성 |

## 남은 일 (ANU)

1. PR 리뷰 및 머지 — **팀은 머지하지 않았다.**
2. MIN_SAMPLE_SIZE=1 하향의 표본부족 가드 약화 여부 판단
3. `_parse_blog_item` 의 `.nblg` 다중 매치 잠재 취약점 후속 여부 판단

## 세션 통계
- 총 도구 호출: 0회

