네이버 AI 브리핑은 공식 API가 없어 지금까지 답변 노출을 재지 못했다. SerpApi naver_ai_overview(제3자 SERP API)로 조회하는 엔진을 붙였다(haewon 결정 2026-09-15). search.naver.com robots.txt 가 수집을 금지하므로 고객 리포트·랜딩에는 넣지 않고 내부 측정까지만 반영했다. 고객 노출은 법무 확인 뒤에 따로 한다. - run_question_bank_openai.py: --engine naver_briefing, --device, --no-cache(반복 측정 시 1시간 캐시 무시). 브리핑이 없는 문항은 answer 를 비우고 briefing=false 로 기록. 인용 출처(sources) 보관 - monthly_ai_measure.py: 기본 엔진 3개(키 없으면 네이버 건너뜀), supporter_builds.report.naverBriefing(노출률·언급·출처 상위, legalReviewed=false) - sentiment_qb.py: 빈 답변은 판정 API를 부르지 않고 미언급 처리 - merge_qb_results.py: NAVER 열·브리핑 없음 표시, 측정일을 생성일이 아닌 엔진별 실제 날짜로 - export_qb_excel.py: 네이버 열·NAVER상세 시트, --out 으로 기존 파일 보존. TOP50 점수는 기존 정의(ChatGPT+Perplexity, 만점 840) 유지 실측(뷰성형외과, 모바일, 2026-09-15): 브리핑 노출 119/120, 브랜드 질문 언급 36/36, 논브랜드 1/84. 인용 출처는 네이버 블로그 87·카페 51·굿닥 23·뷰 공식 20 순. SerpApi 123회, 감성 판정 $0.37. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
269 lines
13 KiB
Python
269 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Question Bank 120문항 + 2엔진 실측 결과를 엑셀(xlsx)로 내보낸다.
|
|
|
|
시트: 요약 / 통합결과 / 병원빈도 / TOP50 / GPT상세 / PPLX상세 / (NAVER상세)
|
|
네이버 AI 브리핑(qb_naver_briefing_results.jsonl)이 있으면 요약·통합결과·병원빈도에 열을 더하고 상세 시트를 만든다.
|
|
TOP50 점수는 기존 정의(ChatGPT+Perplexity 합산, 만점 840)를 유지한다. 네이버를 넣으면 과거 수치와 비교할 수 없다.
|
|
"""
|
|
import json
|
|
import os
|
|
import time
|
|
|
|
from openpyxl import Workbook
|
|
from openpyxl.styles import Alignment, Font, PatternFill
|
|
from openpyxl.utils import get_column_letter
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
QUESTIONS = os.path.join(ROOT, "scripts", "question_bank_viewclinic.json")
|
|
OUT = os.path.join(ROOT, "docs", "reports", "viewclinic", "03_question_bank", "Viewclinic_QB_Results_TOP50.xlsx")
|
|
|
|
INK = "0A1128"
|
|
VIOLET = "4F1DA1"
|
|
LIGHT = "F7F8FC"
|
|
GOOD = "DCFCE7"
|
|
BAD = "FEE2E2"
|
|
WARN = "FEF9C3"
|
|
|
|
CAT_LABEL = {
|
|
"A": "브랜드 직접", "B": "위치·운영", "C": "지역 추천·비교",
|
|
"D1": "가슴성형", "D2": "눈성형", "D3": "코성형", "D4": "안면윤곽·양악",
|
|
"D5": "지방성형", "D6": "피부·리프팅", "D7": "재수술",
|
|
"E": "신뢰·안전", "F": "가격·이벤트", "G": "후기·평판",
|
|
}
|
|
|
|
|
|
def load(engine):
|
|
p = os.path.join(ROOT, "scripts", "out", f"qb_{engine}_results.jsonl")
|
|
rows = {}
|
|
if not os.path.exists(p):
|
|
return rows
|
|
for line in open(p):
|
|
if line.strip():
|
|
r = json.loads(line)
|
|
rows[r["id"]] = r
|
|
return rows
|
|
|
|
|
|
def naver_cells(r):
|
|
if not r:
|
|
return ["미실측", "", ""]
|
|
if not r.get("briefing"):
|
|
return ["브리핑 없음", "", ""]
|
|
clinics = ", ".join([c for c in r.get("clinics", []) if isinstance(c, str)][:5])
|
|
return ["O" if r.get("view_mentioned") else "X", r.get("view_rank") or "", clinics or "(병원 언급 없음)"]
|
|
|
|
|
|
def style_header(ws, ncols, row=1):
|
|
for c in range(1, ncols + 1):
|
|
cell = ws.cell(row=row, column=c)
|
|
cell.font = Font(bold=True, color="FFFFFF", size=11)
|
|
cell.fill = PatternFill("solid", fgColor=INK)
|
|
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
|
ws.freeze_panes = ws.cell(row=row + 1, column=1)
|
|
|
|
|
|
def main():
|
|
import argparse
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--out", default=None, help="저장 경로. 기본은 기존 TOP50 파일(덮어씀)")
|
|
global OUT
|
|
OUT = ap.parse_args().out or OUT
|
|
qs = json.load(open(QUESTIONS))
|
|
oa, px, nv = load("openai"), load("perplexity"), load("naver_briefing")
|
|
wb = Workbook()
|
|
|
|
# ── 요약 ─────────────────────────────────────────────
|
|
ws = wb.active
|
|
ws.title = "요약"
|
|
ws["A1"] = "뷰성형외과 AI Discovery Question Bank 실측 결과"
|
|
ws["A1"].font = Font(bold=True, size=16, color=INK)
|
|
def days(d):
|
|
ds = sorted({(r.get("ts") or "")[:10] for r in d.values()} - {""})
|
|
return "미상" if not ds else ds[0] if ds[0] == ds[-1] else f"{ds[0]}~{ds[-1]}"
|
|
ws["A2"] = (f"측정일 ChatGPT {days(oa)} · Perplexity {days(px)}" + (f" · 네이버 {days(nv)}" if nv else "") + " · OpenAI Responses API + web_search(gpt-4o, 서울 고정) / Perplexity API(sonar)"
|
|
+ (" / 네이버 AI 브리핑(SerpApi naver_ai_overview, 제3자 SERP API, 모바일. 고객 리포트 사용 전 법무 확인 필요)" if nv else ""))
|
|
ws["A3"] = "답변엔진 소비자 UI 자동 조회는 약관 위반이므로 정식 API로 측정. 문의 o2oteam@o2o.kr"
|
|
ws["A2"].font = ws["A3"].font = Font(size=10, color="666B7A")
|
|
|
|
def brand(q):
|
|
return "뷰성형외과" in q["question"]
|
|
|
|
ws.append([])
|
|
ws.append(["구분", "문항 수", "ChatGPT 뷰 언급", "ChatGPT 언급률", "Perplexity 뷰 언급", "Perplexity 언급률"]
|
|
+ (["네이버 브리핑 노출", "네이버 뷰 언급", "네이버 언급률"] if nv else []))
|
|
hr = ws.max_row
|
|
for label, group in (("브랜드 질문 (질문에 병원명 포함)", [q for q in qs if brand(q)]),
|
|
("논브랜드 질문 (추천·비교·시술 정보)", [q for q in qs if not brand(q)]),
|
|
("전체", qs)):
|
|
om = sum(1 for q in group if oa[q["id"]].get("view_mentioned"))
|
|
pm = sum(1 for q in group if px[q["id"]].get("view_mentioned"))
|
|
row = [label, len(group), om, f"{om*100//len(group)}%", pm, f"{pm*100//len(group)}%"]
|
|
if nv:
|
|
ng = [q for q in group if q["id"] in nv]
|
|
nshow = sum(1 for q in ng if nv[q["id"]].get("briefing"))
|
|
nm = sum(1 for q in ng if nv[q["id"]].get("view_mentioned"))
|
|
row += [f"{nshow}/{len(ng)}", nm, f"{nm*100//max(len(ng), 1)}%"]
|
|
ws.append(row)
|
|
style_header(ws, 9 if nv else 6, hr)
|
|
ws.append([])
|
|
ws.append(["사전판정 분포", "◯ 인용 기대 5", "△ 불안정 35", "✕ 인용 불가 80"])
|
|
ws.cell(row=ws.max_row, column=1).font = Font(bold=True)
|
|
for col, w in zip("ABCDEFGHI", [34, 10, 16, 14, 16, 14, 16, 14, 14]):
|
|
ws.column_dimensions[col].width = w
|
|
|
|
# ── 통합결과 ─────────────────────────────────────────
|
|
ws = wb.create_sheet("통합결과")
|
|
ws.append(["ID", "카테고리", "질문", "퍼널", "브랜드", "사전판정",
|
|
"GPT 언급", "GPT 순위", "GPT 상위 병원 (등장순)",
|
|
"PPLX 언급", "PPLX 순위", "PPLX 상위 병원 (등장순)"]
|
|
+ (["NAVER 언급", "NAVER 순위", "NAVER 상위 병원 (등장순)"] if nv else []))
|
|
style_header(ws, 15 if nv else 12)
|
|
for q in qs:
|
|
o, p = oa[q["id"]], px[q["id"]]
|
|
oc = ", ".join([c for c in o.get("clinics", []) if isinstance(c, str)][:5])
|
|
pc = ", ".join([c for c in p.get("clinics", []) if isinstance(c, str)][:5])
|
|
ws.append([q["id"], CAT_LABEL[q["id"].split("-")[0]], q["question"], q["stage"],
|
|
"O" if brand(q) else "", q["prior"],
|
|
"O" if o.get("view_mentioned") else "X", o.get("view_rank") or "",
|
|
oc or "(병원 언급 없음)",
|
|
"O" if p.get("view_mentioned") else "X", p.get("view_rank") or "",
|
|
pc or "(병원 언급 없음)"]
|
|
+ (naver_cells(nv.get(q["id"])) if nv else []))
|
|
r = ws.max_row
|
|
for col, eng in ((7, o), (10, p)) + (((13, nv.get(q["id"]) or {}),) if nv else ()):
|
|
ws.cell(row=r, column=col).fill = PatternFill(
|
|
"solid", fgColor=GOOD if eng.get("view_mentioned") else (WARN if eng.get("engine") == "naver_briefing" and not eng.get("briefing") else BAD))
|
|
for c in range(1, 16 if nv else 13):
|
|
ws.cell(row=r, column=c).alignment = Alignment(vertical="top", wrap_text=c in (3, 9, 12, 15))
|
|
for col, w in zip(range(1, 16), [8, 13, 44, 7, 7, 9, 9, 9, 44, 9, 9, 44, 11, 9, 44]):
|
|
ws.column_dimensions[get_column_letter(col)].width = w
|
|
|
|
# ── 병원빈도 (논브랜드 한정) ─────────────────────────
|
|
ws = wb.create_sheet("병원빈도")
|
|
ws.append(["엔진", "병원", "언급 질문 수", "1순위 등장 수"])
|
|
style_header(ws, 4)
|
|
|
|
def canon(name):
|
|
n = name.strip().lower().replace(" ", "")
|
|
if ("view" in n and ("plastic" in n or "성형" in n)) or n.startswith("뷰성형"):
|
|
return "뷰성형외과"
|
|
for suf in ("성형외과의원", "성형외과", "의원", "클리닉", "clinic", "plasticsurgery"):
|
|
if n.endswith(suf) and len(n) > len(suf):
|
|
return n[: -len(suf)]
|
|
return n
|
|
|
|
for eng_label, data in (("ChatGPT(OpenAI)", oa), ("Perplexity", px)) + ((("네이버 AI 브리핑", nv),) if nv else ()):
|
|
freq, label = {}, {}
|
|
for q in qs:
|
|
if brand(q) or q["id"] not in data:
|
|
continue
|
|
seen = set()
|
|
for i, c in enumerate([c for c in data[q["id"]].get("clinics", []) if isinstance(c, str)][:5]):
|
|
key = canon(c)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
label.setdefault(key, c)
|
|
freq.setdefault(key, [0, 0])
|
|
freq[key][0] += 1
|
|
if i == 0:
|
|
freq[key][1] += 1
|
|
for key, (cnt, first) in sorted(freq.items(), key=lambda x: -x[1][0]):
|
|
ws.append([eng_label, label[key], cnt, first])
|
|
if key == "뷰성형외과":
|
|
for c in range(1, 5):
|
|
ws.cell(row=ws.max_row, column=c).fill = PatternFill("solid", fgColor=WARN)
|
|
for col, w in zip("ABCD", [16, 30, 13, 13]):
|
|
ws.column_dimensions[col].width = w
|
|
|
|
# ── TOP20 (논브랜드 84문항 · 2엔진 합산 랭킹) ─────────
|
|
ws = wb.create_sheet("TOP50")
|
|
ws["A1"] = "성형외과 AI Discovery TOP 50 (논브랜드 84문항 · ChatGPT+Perplexity 합산)"
|
|
ws["A1"].font = Font(bold=True, size=14, color=INK)
|
|
ws["A2"] = "점수 = 답변 내 등장 순위 가중치(1위 5점 ~ 5위 1점)를 질문·엔진 단위로 합산. 브랜드 직접 질문 36문항은 제외."
|
|
ws["A2"].font = Font(size=10, color="666B7A")
|
|
ws.append([])
|
|
ws.append(["순위", "병원", "AI Discovery 점수", "언급 질문 수(합산)", "GPT 언급", "PPLX 언급", "1순위 등장", "주요 등장 카테고리"])
|
|
hr = ws.max_row
|
|
style_header(ws, 8, hr)
|
|
|
|
agg = {}
|
|
for eng_key, data in (("gpt", oa), ("pplx", px)):
|
|
for q in qs:
|
|
if brand(q):
|
|
continue
|
|
seen = set()
|
|
for i, c in enumerate([c for c in data[q["id"]].get("clinics", []) if isinstance(c, str)][:5]):
|
|
key = canon(c)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
a = agg.setdefault(key, {"label": "뷰성형외과" if key == "뷰성형외과" else c,
|
|
"score": 0, "gpt": 0, "pplx": 0,
|
|
"first": 0, "cats": {}})
|
|
a["score"] += 5 - i
|
|
a[eng_key] += 1
|
|
if i == 0:
|
|
a["first"] += 1
|
|
cat = CAT_LABEL[q["id"].split("-")[0]]
|
|
a["cats"][cat] = a["cats"].get(cat, 0) + 1
|
|
|
|
ranked = sorted(agg.values(), key=lambda a: (-a["score"], -a["first"], -(a["gpt"] + a["pplx"])))
|
|
view_rank = next((i + 1 for i, a in enumerate(ranked) if canon(a["label"]) == "뷰성형외과"), None)
|
|
shown = ranked[:50]
|
|
if view_rank and view_rank > 50:
|
|
shown = ranked[:50] + [ranked[view_rank - 1]]
|
|
for idx, a in enumerate(shown):
|
|
rank = idx + 1 if idx < 50 else view_rank
|
|
cats = " · ".join(k for k, _ in sorted(a["cats"].items(), key=lambda x: -x[1])[:3])
|
|
ws.append([rank, a["label"], a["score"], a["gpt"] + a["pplx"], a["gpt"], a["pplx"], a["first"], cats])
|
|
if canon(a["label"]) == "뷰성형외과":
|
|
for c in range(1, 9):
|
|
ws.cell(row=ws.max_row, column=c).fill = PatternFill("solid", fgColor=WARN)
|
|
ws.cell(row=ws.max_row, column=c).font = Font(bold=True)
|
|
for col, w in zip("ABCDEFGH", [6, 30, 16, 16, 10, 10, 10, 40]):
|
|
ws.column_dimensions[col].width = w
|
|
|
|
# ── 엔진별 상세 (답변 전문 + 인용) ────────────────────
|
|
# 논브랜드 84문항만 수록 (브랜드 직접 질문 36개의 원본 답변은 scripts/out/ JSONL 참조)
|
|
if nv:
|
|
ws = wb.create_sheet("NAVER상세")
|
|
ws.append(["ID", "질문 (120문항)", "브리핑", "뷰 언급", "순위", "브리핑 전문", "인용 출처 (출처명 · 링크)"])
|
|
style_header(ws, 7)
|
|
for q in qs:
|
|
r = nv.get(q["id"])
|
|
if not r:
|
|
continue
|
|
srcs = "\n".join(f"{x.get('source') or ''} · {x.get('link') or ''}" for x in r.get("sources") or [])
|
|
ws.append([q["id"], q["question"], "O" if r.get("briefing") else "없음",
|
|
"O" if r.get("view_mentioned") else "X", r.get("view_rank") or "",
|
|
(r.get("answer") or "")[:32000], srcs])
|
|
for c in range(1, 8):
|
|
ws.cell(row=ws.max_row, column=c).alignment = Alignment(vertical="top", wrap_text=c in (2, 6, 7))
|
|
for col, w in zip("ABCDEFG", [8, 36, 8, 8, 6, 90, 60]):
|
|
ws.column_dimensions[col].width = w
|
|
|
|
for sheet, data in (("GPT상세", oa), ("PPLX상세", px)):
|
|
ws = wb.create_sheet(sheet)
|
|
ws.append(["ID", "질문 (논브랜드 84문항)", "뷰 언급", "순위", "답변 전문", "인용 URL"])
|
|
style_header(ws, 6)
|
|
for q in qs:
|
|
if brand(q):
|
|
continue
|
|
r = data[q["id"]]
|
|
ws.append([q["id"], q["question"],
|
|
"O" if r.get("view_mentioned") else "X", r.get("view_rank") or "",
|
|
r.get("answer", "")[:32000], "\n".join(r.get("citations", [])[:10])])
|
|
row = ws.max_row
|
|
for c in range(1, 7):
|
|
ws.cell(row=row, column=c).alignment = Alignment(vertical="top", wrap_text=c in (2, 5, 6))
|
|
for col, w in zip("ABCDEF", [8, 36, 8, 6, 90, 50]):
|
|
ws.column_dimensions[col].width = w
|
|
|
|
wb.save(OUT)
|
|
print("저장:", OUT)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|