# InfoKeyword Step 5/6/7 개선 설계서

**작성일**: 2026-03-04
**작성팀**: 개발2팀 (오딘, 토르, 프레이야, 미미르)
**상태**: 설계 완료 — 1팀 Step 2/3/4 수정 결과와 Merge 후 구현 예정

---

## 목차

1. [배경 및 요구사항](#1-배경-및-요구사항)
2. [네이버 블로그 탭 "광고" 식별 조사 결과](#2-네이버-블로그-탭-광고-식별-조사-결과)
3. [Step 5: 광고성 여부 개선](#3-step-5-광고성-여부-개선)
4. [Step 6: 외부 블로그 개선](#4-step-6-외부-블로그-개선)
5. [Step 7: 카페 뱃지 개선](#5-step-7-카페-뱃지-개선)
6. [영향 범위 분석](#6-영향-범위-분석)
7. [구현 우선순위 및 의존관계](#7-구현-우선순위-및-의존관계)

---

## 1. 배경 및 요구사항

### 제이회장님 요구사항

**Step 5 (광고성 여부)**
- 블로그 탭 TOP10 중 "광고" 표시가 있는 건 제외하고 분석
- 나머지 블로그 중 몇 개가 광고/홍보성이고 몇 개가 정보성인지 숫자로 표시
- 위치 정보 표시: "정보성: Top 1, 3, 5위" / "홍보성: Top 2, 4, 6, 7위"

**Step 6 (외부 블로그)**
- 외부 블로그가 몇 개인지 숫자로 명확히 표기 (예: "외부 블로그 2개 / TOP10 중")

**Step 7 (카페 뱃지)**
- TOP10 중 대표카페가 몇 개인지 숫자로 표기 (예: "대표카페 3개 / TOP10 중")

---

## 2. 네이버 블로그 탭 "광고" 식별 조사 결과

### 2.1 광고 유형 분류

네이버 블로그 탭 검색 결과의 광고는 크게 두 층위로 구분됩니다.

**유형 1: 파워컨텐츠/파워링크 — 네이버 시스템 광고**
- 2024년 2월 15일부터 블로그 탭에 파워컨텐츠 노출 시작
- 2024년 8월부터 블로그 탭에 사이트검색광고(파워링크) 확장
- HTML 마커: `_fe_view_power_content` 클래스, `adcr.naver.com`/`ader.naver.com` URL, `integration_66.naver` 도메인
- CPC(클릭당 과금) 방식

**유형 2: 체험단/협찬 — 블로거 자체 광고 표기**
- 공정거래위원회 고시에 따른 의무 표기 (2024년 12월 개정)
- HTML 구조상 일반 포스트와 동일 — CSS 클래스나 data 속성 부여 안 됨
- snippet 텍스트에 "광고", "[협찬]", "[체험단]" 키워드 포함 가능
- `_is_ad()` 감지 범위 밖으로 두는 것이 올바른 설계

### 2.2 현재 `_is_ad()` 구현 분석

**파일**: `/home/jay/projects/InfoKeyword/worker/crawler/blog_search.py`

현재 감지 방식:
- CSS 클래스 `_fe_view_power_content` — 파워컨텐츠 감지
- 도메인 `integration_66.naver` — 광고 CDN 도메인 감지

놓치고 있는 감지 지표:
- `adcr.naver.com` / `ader.naver.com` — 광고 클릭 리다이렉트 도메인
- `data-index` 속성 — 파워링크 계열 광고 마커
- "광고" 텍스트 라벨 — span/div 요소 내 텍스트

### 2.3 `_is_ad()` 보완 제안

```python
# 추가할 광고 감지 패턴
_AD_REDIRECT_DOMAINS = ("adcr.naver.com", "ader.naver.com")
_AD_CLASSES = {"ad_area", "ico_ad", "power_content"}

def _is_ad(item: Tag) -> bool:
    # [기존] CSS 클래스 기반 감지
    if item.has_attr("class") and _AD_CLASS in item.get("class", []):
        return True
    if item.find(class_=_AD_CLASS):
        return True

    # [기존] 도메인 기반 감지
    for anchor in item.find_all("a", href=True):
        href = anchor["href"]
        if _AD_DOMAIN in href:
            return True

    # [추가 1] adcr/ader 리다이렉트 도메인 감지
    for anchor in item.find_all("a", href=True):
        href = anchor["href"]
        if any(d in href for d in _AD_REDIRECT_DOMAINS):
            return True

    # [추가 2] "광고" 텍스트 라벨 감지
    for el in item.find_all(["span", "div", "em"]):
        text = el.get_text(strip=True)
        if text in ("광고", "AD", "Sponsored"):
            return True

    # [추가 3] data-index 속성 (파워링크 확장)
    if item.has_attr("data-index"):
        return True

    # [추가 4] 광고 전용 CSS 클래스 변형
    element_classes = set(item.get("class", []))
    if element_classes & _AD_CLASSES:
        return True

    return False
```

> **참고**: `data-template-id="ugcItem"` 속성이 있으면 일반 UGC 포스트이므로 광고가 아님. 화이트리스트 기반 필터링도 보조적으로 활용 가능.

> **주의**: 실제 HTML 구조는 네이버 업데이트에 따라 변경될 수 있으므로, 구현 전 브라우저 개발자 도구로 직접 DOM 구조 확인 필수.

---

## 3. Step 5: 광고성 여부 개선

### 3.1 현재 동작 (As-Is)

**Worker 흐름**:
1. `search_blogs()` → 네이버 블로그 탭에서 TOP10 결과 스크래핑
2. `_is_ad()`로 광고 항목 필터링 (크롤러 단계에서 제거)
3. 광고 제외된 리스트를 `_step5_promotional()`에 전달
4. `_analyze_single_blog()`로 각 블로그 홍보성 분석 (전화번호/주소, 외부링크, 첨부, 이미지, LLM)
5. 결과: `{pass, ratio, promotional_count, total_analyzed, details}`

**Frontend 표시**:
- "홍보성 X/Y (ratio%)" 요약
- 블로그 목록: 빨간점(홍보)/초록점(비홍보) + 블로그명 + URL + reasons

### 3.2 문제점

- 광고 항목이 크롤러 단계에서 완전히 제거되어, 광고가 몇 개 제외됐는지 사용자가 알 수 없음
- 각 블로그의 검색 결과 내 순위(`rank`) 정보가 없음
- 정보성 블로그 개수를 별도로 강조하지 않음
- "정보성: Top 1, 3, 5위" 형태의 위치 정보 미제공
- `_is_ad()` 함수가 `adcr.naver.com` 등 일부 광고 패턴을 놓칠 수 있음

### 3.3 개선안 (To-Be)

#### Worker 측 변경

**크롤러 (`blog_search.py`) 변경**:
- 기존: 광고를 크롤러에서 필터링하여 제거
- 개선: 각 항목에 `rank`(1-based) 부여 + `is_ad` 플래그 태깅, 전체 리스트 반환
- `_is_ad()` 함수 보완 (섹션 2.3 참조)

```
변경 전: 스크래핑 → _is_ad() 필터링 → 광고 제외된 리스트 반환
변경 후: 스크래핑 → 각 항목에 rank 부여 + is_ad 태깅 → 전체 리스트 반환
```

**분석기 (`analyzer.py` - `_step5_promotional()`) 변경**:
- `is_ad=True` 항목 → 분석 건너뜀, `ad_excluded_positions` 리스트에 rank 기록
- `is_ad=False` 항목 → `_analyze_single_blog()` 호출
- 분석 결과에서 `is_promotional` 여부로 `informational_positions` / `promotional_positions` 분리

**`_parse_blog_item()` 반환값 변경**:
```python
# 추가 필드
{
    "title": "...",
    "url": "...",
    "blog_name": "...",
    "is_naver_blog": True,
    "source_domain": "blog.naver.com",
    "rank": 1,        # 추가: 검색 결과 순위 (1-based)
    "is_ad": False,    # 추가: 광고 여부
}
```

#### Frontend 측 변경

**영역 A: 광고 제외 안내 배너 (조건부)**
- 광고 항목이 1개 이상일 때만 표시
- `"ℹ 광고 항목 N개 제외 후 분석 (실질 분석: M개)"`
- `bg-slate-50 text-slate-400 text-xs`

**영역 B: 정보성/홍보성 숫자 카드 (2분할)**

```
┌───────────────────────┐  ┌───────────────────────┐
│  정보성               │  │  홍보성               │
│       3개             │  │       4개             │
│  Top 1, 3, 5위        │  │  Top 2, 4, 6, 7위     │
└───────────────────────┘  └───────────────────────┘
   bg-emerald-50             bg-rose-50
```
- 숫자는 `text-2xl font-bold`로 강조
- 위치 정보는 `text-xs text-slate-500`

**영역 C: 블로그 목록 (상세 리스트)**
- 기존 리스트 구조 유지 + 각 항목 앞에 `[TOP N]` 순위 배지 추가
- 광고 항목은 `[광고]` 배지 + `line-through opacity-50` 처리

### 3.4 데이터 스키마 변경

```json
{
  "pass": true,
  "ratio": 0.3,
  "promotional_count": 3,
  "informational_count": 5,
  "ad_excluded_count": 2,
  "total_crawled": 10,
  "total_analyzed": 8,
  "informational_positions": [1, 3, 5, 8, 9],
  "promotional_positions": [2, 4, 6],
  "ad_excluded_positions": [7, 10],
  "details": [
    {
      "rank": 1,
      "url": "https://blog.naver.com/...",
      "blog_name": "블로거A",
      "is_naver_blog": true,
      "is_ad": false,
      "is_promotional": false,
      "reasons": []
    },
    {
      "rank": 7,
      "url": "https://blog.naver.com/...",
      "blog_name": "광고블로그",
      "is_naver_blog": true,
      "is_ad": true,
      "is_promotional": null,
      "reasons": ["광고 항목으로 분석 제외"]
    }
  ]
}
```

**추가 필드 요약**:
- `informational_count` (int): 정보성 판별 개수
- `ad_excluded_count` (int): 광고 제외 개수
- `total_crawled` (int): 광고 포함 전체 크롤링 수
- `informational_positions` (list[int]): 정보성 항목 순위 목록
- `promotional_positions` (list[int]): 홍보성 항목 순위 목록
- `ad_excluded_positions` (list[int]): 광고 제외 항목 순위 목록
- `details[].rank` (int): 각 항목의 노출 순위 (1-based)
- `details[].is_ad` (bool): 광고 여부
- `details[].is_promotional` (bool|null): 광고이면 null

**TypeScript 타입 추가**:
```typescript
export interface Step5BlogDetail {
  url: string;
  blog_name: string;
  is_promotional: boolean | null;
  is_ad: boolean;
  rank?: number;
  reasons: string[];
}

export interface Step5Result extends StepResult {
  ratio?: number;
  promotional_count?: number;
  informational_count?: number;
  total_analyzed?: number;
  ad_excluded_count?: number;
  informational_positions?: number[];
  promotional_positions?: number[];
  ad_excluded_positions?: number[];
  details?: Step5BlogDetail[];
}
```

---

## 4. Step 6: 외부 블로그 개선

### 4.1 현재 동작 (As-Is)

**Worker 흐름**:
1. `_step6_external_blog()`가 `search_blogs()` 결과에서 `is_naver_blog=False` 항목 필터링
2. 결과: `{pass, external_count, external_blogs}` (pass = external_count == 0)

**Frontend 표시**:
- 0개: "외부 블로그 없음" (italic 회색)
- N개: URL 링크 목록 또는 "외부 블로그 N개" 텍스트

### 4.2 문제점

- "TOP10 중"이라는 분모 표현이 없어 수치의 맥락 부재
- 외부 블로그의 순위 정보 없음
- 0개일 때 "없음"이라는 표현이 분석 결과보다는 데이터 부재처럼 보임

### 4.3 개선안 (To-Be)

#### Worker 측 변경

Step 5 개선 후 `details`에 `rank` 필드가 추가되므로 이를 활용합니다.

```python
def _step6_external_blog(blogs: list[dict]) -> dict:
    external_blogs = [b for b in blogs if not b.get("is_naver_blog", True) and not b.get("is_ad", False)]
    external_count = len(external_blogs)
    total_base = len([b for b in blogs if not b.get("is_ad", False)])
    external_positions = sorted([b.get("rank", 0) for b in external_blogs if b.get("rank")])
    passed = external_count == 0

    return {
        "pass": passed,
        "external_count": external_count,
        "total_base": total_base,
        "external_positions": external_positions,
        "external_blogs": external_blogs,
    }
```

#### Frontend 측 변경

**핵심 표시 형식**:
```
┌──────────────────────────────────────────────────┐
│    외부 블로그    2개    / TOP10 중               │
└──────────────────────────────────────────────────┘
```
- "2개"는 `text-lg font-bold text-slate-800`
- "/ TOP10 중"은 `text-xs text-slate-400`

**0개일 때**: 0도 명시적으로 표시 + "모든 결과가 네이버 블로그입니다" 보조 텍스트

**URL 목록**: 순위 배지 포함
```
  TOP 3   tistory.com/some-blog    ↗
  TOP 7   velog.io/some-post       ↗
```

### 4.4 데이터 스키마 변경

```json
{
  "pass": true,
  "external_count": 2,
  "total_base": 8,
  "external_positions": [3, 7],
  "external_blogs": [
    {
      "rank": 3,
      "url": "https://tistory.com/...",
      "blog_name": "...",
      "source_domain": "tistory.com"
    }
  ]
}
```

**추가 필드**:
- `total_base` (int): 광고 제외 후 전체 분석 대상 수
- `external_positions` (list[int]): 외부 블로그 순위 목록

**TypeScript 타입 추가**:
```typescript
export interface Step6Result extends StepResult {
  external_count?: number;
  total_base?: number;
  external_positions?: number[];
  external_blogs?: Array<{
    rank?: number;
    url: string;
    blog_name?: string;
    source_domain?: string;
  }>;
}
```

---

## 5. Step 7: 카페 뱃지 개선

### 5.1 현재 동작 (As-Is)

**Worker 흐름**:
1. `count_cafe_badges()`가 카페 검색 결과에서 대표 뱃지 카운트
2. `_has_representative_badge()`로 `i.spnew.api_ico_total` 또는 "대표" 텍스트 검색
3. 결과: `{pass, representative_count, total_results, details}`

**Frontend 표시**:
- "대표뱃지 X / 전체 Y" + 퍼센트 뱃지

### 5.2 문제점

- "대표뱃지"라는 용어 사용 (요구사항은 "대표카페")
- "전체"라는 모호한 분모 (TOP10인지 전체 카페 결과인지 불명확)
- 대표카페의 순위 정보 없음
- 퍼센트와 숫자가 같은 줄에 있어 시각적 위계 부족

### 5.3 개선안 (To-Be)

#### Worker 측 변경

`count_cafe_badges()`에 `rank` 부여 + `representative_positions` 리스트 생성

```python
# _parse_cafe_item() 반환값에 rank 추가
# count_cafe_badges() 결과에 representative_positions 추가
```

#### Frontend 측 변경

**핵심 표시 형식**:
```
┌──────────────────────────────────────────────────┐
│    대표카페    3개    / TOP10 중                  │
│    ████████░░░░░░░░░░░░░░░░  30%                 │
└──────────────────────────────────────────────────┘
```
- "대표뱃지" → "대표카페"로 레이블 변경
- "전체 Y" → "TOP10 중"으로 문맥 명확화
- 퍼센트를 Progress bar로 시각화 (이미 `Progress` 컴포넌트 import 되어 있음)

### 5.4 데이터 스키마 변경

```json
{
  "pass": false,
  "representative_count": 3,
  "total_results": 10,
  "representative_positions": [1, 4, 8],
  "details": [
    {
      "rank": 1,
      "cafe_name": "대표카페A",
      "title": "...",
      "url": "...",
      "is_representative": true
    }
  ]
}
```

**추가 필드**:
- `representative_positions` (list[int]): 대표카페 순위 목록
- `details[].rank` (int): 각 카페의 노출 순위 (1-based)

**TypeScript 타입 추가**:
```typescript
export interface Step7Result extends StepResult {
  representative_count?: number;
  total_results?: number;
  representative_positions?: number[];
  details?: Array<{
    rank?: number;
    cafe_name: string;
    title: string;
    url: string;
    is_representative: boolean;
  }>;
}
```

---

## 6. 영향 범위 분석

### 6.1 Worker 측 영향

**변경 파일 목록**:
- `worker/crawler/blog_search.py` — `_is_ad()` 보완, `_parse_blog_item()`에 rank/is_ad 추가, `search_blogs()` 반환 구조 변경
- `worker/pipeline/analyzer.py` — `_step5_promotional()` positions 계산, `_step6_external_blog()` total_base/positions 추가, `_step7_cafe_badge()` positions 추가
- `worker/crawler/cafe_search.py` — `_parse_cafe_item()`에 rank 추가, `count_cafe_badges()` positions 추가

**영향 없는 파일**:
- `worker/crawler/blog_content.py` — 변경 불필요
- `worker/analyzer/` 하위 모듈들 — 변경 불필요 (입출력 인터페이스 동일)
- `worker/reporter/` — 변경 불필요 (report_generator는 result dict 전체를 받으므로 추가 필드 자동 포함)

### 6.2 Frontend 측 영향

**변경 파일 목록**:
- `src/app/report/[id]/page.tsx` — `renderStepDetail()` 내 step5/6/7 케이스 변경
- `src/types/index.ts` — Step5/6/7 전용 타입 추가

**영향 없는 파일**:
- `src/components/step-badge.tsx` — pass/fail 뱃지 로직은 변경 불필요
- `src/components/analysis-summary.tsx` — 전체 요약은 기존 verdict/passed_steps 기반이므로 변경 불필요

### 6.3 기존 데이터 호환성

- 기존 필드(`pass`, `ratio`, `promotional_count`, `total_analyzed`, `external_count`, `representative_count`, `total_results`)는 모두 유지
- 신규 필드만 추가이므로 하위 호환성 보장
- Firestore에 이미 저장된 데이터에는 신규 필드가 없으므로, Frontend에서 `undefined` 체크 필요 (optional chaining)
- `pass` 판정 조건은 변경 없음

### 6.4 Step 간 의존관계

- Step 5 → Step 6: Step 6는 Step 5의 `blogs` 리스트를 재사용하므로, Step 5에서 `rank`/`is_ad` 필드가 추가되면 Step 6가 이를 활용 가능. **Step 5 개선이 선행 필요**
- Step 7: 카페 검색은 독립 실행이므로 Step 5/6 변경과 무관하게 단독 개선 가능

---

## 7. 구현 우선순위 및 의존관계

### 구현 순서

1. **Step 7 (카페 뱃지)** — 난이도: 낮음, 독립적
   - `cafe_search.py`에 rank 태깅
   - `analyzer.py`에 `representative_positions` 추가
   - Frontend 레이블/레이아웃 변경

2. **Step 5 (광고성 여부)** — 난이도: 중간, 핵심 변경
   - `blog_search.py` `_is_ad()` 보완 + rank/is_ad 태깅
   - `analyzer.py` `_step5_promotional()` positions 분리
   - Frontend 3영역 레이아웃 (광고 제외 배너 + 숫자 카드 + 목록)

3. **Step 6 (외부 블로그)** — 난이도: 낮음, Step 5 선행 필요
   - `analyzer.py` `_step6_external_blog()` total_base/positions 추가
   - Frontend 숫자 카드 패턴 적용

### 1팀 Merge 고려사항

- 1팀이 Step 2/3/4 수정을 진행 중이며, `analyzer.py`의 `analyze_keyword()` 함수를 공유
- Step 2/3/4와 Step 5/6/7은 `analyze_keyword()` 내에서 별도 블록으로 실행되므로 충돌 위험 낮음
- 다만 `search_blogs()` 반환 구조 변경은 파이프라인 전체에 영향이므로, 1팀과 인터페이스 합의 필요
- `_parse_blog_item()` 반환값에 `rank`/`is_ad` 필드 추가는 기존 필드를 제거하지 않으므로 안전

### 공통 설계 원칙

1. `rank`는 1-based 정수 (노출 순위 1위 = rank=1)
2. 광고 항목은 분석 제외하되 rank는 보존 (`is_ad=True` → `is_promotional=null`)
3. 기존 필드는 모두 유지하여 하위 호환성 보장
4. Frontend에서 `rank` 데이터 미제공 시 graceful degradation (위치 정보 줄 숨김)
5. Step 6/7은 동일한 "N개 / TOP10 중" 카드 패턴 사용 → 공통 컴포넌트 분리 권장
