o2o-infinith-demo/scripts/build_supporters_audit_xlsx.py
Haewon Kam 03ed725481 feat: 뷰성형외과 서포터즈 샘플 사이트 v3 + 설계 v0.1 + 공식 사이트 대비 AEO 감사
- supporters/: Astro 사이트 소스 (pages·components·posts·factSheet/authors/videos 데이터, 뉴스룸 916건 빌더, 발행 게이트 check.mjs, vercel.json)
- supporters/docs/: v1~v3 스크린샷 기록
- docs/INFINITH_Viewclinic_Supporters_Site_Design_v0.1.md: 서포터즈 사이트 설계 문서
- docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD.md: 다음 세션 자동 빌드 프롬프트
- docs/reports/Viewclinic_Supporters_vs_Official_AEO_Audit.xlsx + scripts/build_supporters_audit_xlsx.py: 서포터즈 vs 공식 사이트 AEO/GEO 감사 비교

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 08:40:10 +09:00

188 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""공식 홈페이지 vs 서포터즈 사이트 AEO/GEO 비교 엑셀.
사용: python3 scripts/build_supporters_audit_xlsx.py <scratch/audit dir> <out.xlsx>
입력: official.json(공식 결과+항목명), supporters_prod.json(샘플 noindex 실측), supporters_idx.json(색인 허용 빌드 실측), overrides.json(수동 판정)"""
import json,sys,os
from openpyxl import Workbook
from openpyxl.styles import Font,PatternFill,Alignment,Border,Side
from openpyxl.utils import get_column_letter
A=sys.argv[1]; OUT=sys.argv[2]
off=json.load(open(f"{A}/official.json")); names=off["names"]
CATS={"A":("AI 크롤러 접근성",20),"B":("엔티티 식별·구조화",20),"C":("답변 인용 가능 콘텐츠",25),"D":("AI 인용 표면 확보",20),"E":("AI 가시성 측정 인프라",10),"F":("기술 위생",5)}
def load(fn):
d=json.load(open(f"{A}/{fn}")); res=d["results"] if isinstance(d,dict) and "results" in d else d
return {r["criterionId"]:r for r in res}
prod=load("supporters_prod.json"); idx=load("supporters_idx.json")
ov=json.load(open(f"{A}/overrides.json")) if os.path.exists(f"{A}/overrides.json") else {}
official={r["id"]:r for r in off["official"]}
def lvl(r): return r["level"] if r else "unverified"
# 색인 허용 열 = 로컬 색인 빌드 실측 + 로컬에서 판정 불가한 항목(A4·B6·A7)은 프로덕션 값 + 수동 판정 override
final={}
for cid in names:
r=idx.get(cid); src="로컬 색인 허용 빌드 실측"
if cid in ("A4","B6","A7"): r=prod.get(cid); src="프로덕션 실측 (로컬에서 판정 불가)"
ev=r["evidence"] if r else ""; L=lvl(r)
if cid in ov: L=ov[cid]["level"]; ev=ov[cid]["evidence"]; src="수동 판정 (서포터즈 실측)"
final[cid]={"level":L,"evidence":ev,"src":src}
W={c:names[c][1] for c in names}
def score(levels):
cat={}
for c in CATS:
e=p=n=0
for cid in names:
if cid[0]!=c: continue
L=levels.get(cid,"unverified")
if L=="unverified": continue
n+=1; p+=W[cid]; e+=W[cid]*L/3
cat[c]=(round(e,1),p,(round(e/p*100) if p else 0),n)
E=sum(v[0] for v in cat.values()); P=sum(v[1] for v in cat.values()); N=sum(v[3] for v in cat.values())
s=round(E/P*100) if P else 0; g="A" if s>=81 else "B" if s>=61 else "C" if s>=41 else "D"
return cat,round(E,1),P,s,g,N
lv_off={c:official[c]["level"] for c in names}; lv_prod={c:lvl(prod.get(c)) for c in names}; lv_fin={c:final[c]["level"] for c in names}
for c in ov:
if lv_prod.get(c)=="unverified": lv_prod[c]=ov[c]["level"]
S_off=score(lv_off); S_prod=score(lv_prod); S_fin=score(lv_fin)
NAVY="0A1128"; VIOLET="4F1DA1"; LIGHT="F4F6FB"; GOOD="F3F0FF"; WARN="FFF6ED"; CRIT="FFF0F0"
hf=Font(name="Pretendard",bold=True,color="FFFFFF",size=14); bf=Font(name="Pretendard",size=14); bb=Font(name="Pretendard",size=14,bold=True)
fill=lambda h:PatternFill("solid",fgColor=h); thin=Side(style="thin",color="E2E8F0"); bd=Border(left=thin,right=thin,top=thin,bottom=thin)
wrap=Alignment(wrap_text=True,vertical="top"); center=Alignment(horizontal="center",vertical="center",wrap_text=True)
wb=Workbook()
# ── 1. 요약
ws=wb.active; ws.title="요약"
ws["A1"]="뷰성형외과 공식 홈페이지 vs 뷰 서포터즈 사이트 · AEO/GEO 기준표 v1.0 비교"; ws["A1"].font=Font(name="Pretendard",bold=True,size=18,color=NAVY)
ws["A2"]="실측일 2026-09-04 · 도구 scripts/audit_aeo_geo.py(자동 21항목) + 수동 판정 · 미검증 항목은 0점이 아니라 분모 제외 · 등급 D≤40 / C 41~60 / B 61~80 / A 81+"; ws["A2"].font=Font(name="Pretendard",size=14,color="64748B")
hdr=["구분","공식 홈페이지 (viewclinic.com)","서포터즈 샘플 (현재·noindex)","서포터즈 색인 허용 빌드","비고"]
for i,h in enumerate(hdr,1):
c=ws.cell(row=4,column=i,value=h); c.font=hf; c.fill=fill(NAVY); c.alignment=center; c.border=bd
rows=[("총점 (100점 환산)",S_off[3],S_prod[3],S_fin[3],"검증 항목 가중 합 ÷ 검증 항목 가중치 합"),
("등급",S_off[4],S_prod[4],S_fin[4],""),
("획득 / 가능 가중치",f"{S_off[1]} / {S_off[2]}",f"{S_prod[1]} / {S_prod[2]}",f"{S_fin[1]} / {S_fin[2]}",""),
("검증 항목 수 (36 중)",S_off[5],S_prod[5],S_fin[5],"D·E 대부분은 오너 계정·수동 실측 필요")]
r=5
for row in rows:
for i,v in enumerate(row,1):
c=ws.cell(row=r,column=i,value=v); c.font=bb if i==1 else bf; c.border=bd; c.alignment=center if i>1 else wrap
r+=1
r+=1
for i,h in enumerate(["카테고리 (가중치)","공식 %","샘플 noindex %","색인 허용 %","차이 (색인 허용 공식)"],1):
c=ws.cell(row=r,column=i,value=h); c.font=hf; c.fill=fill(VIOLET); c.alignment=center; c.border=bd
r+=1
for cat,(nm,w) in CATS.items():
a=S_off[0][cat]; b=S_prod[0][cat]; f=S_fin[0][cat]
vals=[f"{cat}. {nm} ({w})",f"{a[2]}% ({a[3]}/{sum(1 for x in names if x[0]==cat)} 검증)",f"{b[2]}% ({b[3]} 검증)",f"{f[2]}% ({f[3]} 검증)",f"{f[2]-a[2]:+d}p"]
for i,v in enumerate(vals,1):
c=ws.cell(row=r,column=i,value=v); c.font=bf; c.border=bd; c.alignment=center if i>1 else wrap
if i==5: c.fill=fill(GOOD if f[2]-a[2]>0 else CRIT if f[2]-a[2]<0 else LIGHT)
r+=1
r+=1
notes=["읽는 법",
"· 샘플 noindex 열: 지금 배포된 그대로. robots.txt가 전면 차단이라 A1이 0점, sitemap 미선언으로 A5 1점. 의도된 상태입니다(발행 전).",
"· 색인 허용 빌드 열: PUBLIC_INDEXABLE=true로 빌드한 같은 코드. 발행 시 robots 전면 허용 + AI 봇 명시 + sitemap 선언. A4·B6·A7은 로컬에서 판정할 수 없어 프로덕션 실측값을 씁니다.",
"· D(표면)·E(측정)는 사이트가 아니라 병원 엔티티·오너 계정에 걸린 항목입니다. 서포터즈 사이트가 직접 바꿀 수 있는 항목은 A·B·C·F이며, E는 서포터즈 도메인의 서버 로그·GA4를 붙이면 채워집니다.",
"· 공식 홈페이지 수치는 2026-08-31 재채점 기준(50점/C)이며 discovery_viewclinic.ts와 같습니다.",
"· 점수는 '인용된다'는 약속이 아닙니다. 인용될 수 없는 이유를 제거했는지의 체크리스트입니다(INFINITH 정직성 규약)."]
for n in notes:
c=ws.cell(row=r,column=1,value=n); c.font=bb if n=="읽는 법" else bf; c.alignment=wrap; ws.merge_cells(start_row=r,start_column=1,end_row=r,end_column=5); ws.row_dimensions[r].height=44 if n!="읽는 법" else 24; r+=1
for i,w in enumerate([44,36,36,36,56],1): ws.column_dimensions[get_column_letter(i)].width=w
# ── 2. 항목별 비교
ws2=wb.create_sheet("항목별 비교 (36항목)")
hdr=["ID","카테고리","항목","가중치","공식 홈페이지 레벨","공식 근거","서포터즈 샘플 레벨 (noindex)","샘플 근거","서포터즈 색인 허용 레벨","색인 허용 근거","판정 출처","차이 (색인 허용 공식)"]
for i,h in enumerate(hdr,1):
c=ws2.cell(row=1,column=i,value=h); c.font=hf; c.fill=fill(NAVY); c.alignment=center; c.border=bd
r=2
def L2(v): return "미검증" if v=="unverified" else v
for cid in names:
o=official[cid]; p=prod.get(cid); f=final[cid]
pL=lv_prod[cid]; pE=(p["evidence"] if p else "")
if cid in ov and (not p or lvl(p)=="unverified"): pE=ov[cid]["evidence"]
diff="" if (o["level"]=="unverified" or f["level"]=="unverified") else f["level"]-o["level"]
vals=[cid,f"{cid[0]}. {CATS[cid[0]][0]}",names[cid][0],W[cid],L2(o["level"]),o["evidence"],L2(pL),pE,L2(f["level"]),f["evidence"],f["src"],(f"{diff:+d}" if diff!="" else "")]
for i,v in enumerate(vals,1):
c=ws2.cell(row=r,column=i,value=v); c.font=bf; c.border=bd; c.alignment=wrap if i in (3,6,8,10,11) else center
if i in (5,7,9) and v!="미검증": c.fill=fill(GOOD if v==3 else LIGHT if v==2 else WARN if v==1 else CRIT)
if i==12 and diff!="": c.fill=fill(GOOD if diff>0 else CRIT if diff<0 else LIGHT)
r+=1
for i,w in enumerate([8,24,38,9,11,60,12,60,12,60,24,12],1): ws2.column_dimensions[get_column_letter(i)].width=w
ws2.freeze_panes="D2"
# ── 3. 남은 과제
ws3=wb.create_sheet("서포터즈 남은 과제")
todo=[("A4","www 변형 미수렴","vercel.app 임시 도메인은 www 변형이 없음. 정식 도메인(별도 도메인 또는 서브도메인) 확정 후 http/www 4변형 301 통일","도메인 결정 후"),
("B4","주소 표기 2종","팩트 시트 단일 원본으로 통일 완료(2026-09-04 수정). 다음 실측에서 3점 예상","완료"),
("B5","sameAs 인식 1개","MedicalClinic 노드에 sameAs 5개가 있으나 채점기가 Organization 타입 노드를 먼저 읽음. 스키마 노드 순서·타입 보강","코드 수정"),
("B7","hreflang 없음","단일 언어 매체라 해당 없음. 영문판을 만들면 연결","해당 없음"),
("C4","구체 수치","시술 페이지에 절개 길이·수술 시간은 있으나 가격·회복 일수 표 없음. 가격 노출 범위는 경영진 결정 대기","결정 대기"),
("C5","표 1개","시술 글마다 비교표 1~2개로 확대","콘텐츠"),
("D1~D5","표면 항목","병원 엔티티 항목. 서포터즈 사이트가 아니라 병원 계정(강남언니·GBP·플레이스)에서 개선","병원"),
("E1~E4","측정 인프라","서포터즈 도메인에 Vercel 로그 드레인 + GA4 AI 채널그룹 + GSC·Bing 등록. 발행과 동시에 설치","발행 시"),
("F2","CMS 판정 불가","정적 빌드라 CMS·버전 노출 없음. 채점기 기준으로는 미검증이지만 실질 위험은 없음","해당 없음"),
("전제","noindex 해제","법무 확인·도메인 결정·서포터즈 실명·원장 감수 완료 후 PUBLIC_INDEXABLE=true로 발행","경영진")]
for i,h in enumerate(["항목","현재","조치","상태"],1):
c=ws3.cell(row=1,column=i,value=h); c.font=hf; c.fill=fill(VIOLET); c.alignment=center; c.border=bd
for r,row in enumerate(todo,2):
for i,v in enumerate(row,1):
c=ws3.cell(row=r,column=i,value=v); c.font=bf; c.border=bd; c.alignment=wrap
for i,w in enumerate([12,28,100,16],1): ws3.column_dimensions[get_column_letter(i)].width=w
# ── 4. E 측정 연결 가이드
ws4=wb.create_sheet("E 측정 인프라 연결 가이드")
ws4["A1"]="E. AI 가시성 측정 인프라 (가중치 10) · 무엇을 어디서 연결해야 채점되는가"; ws4["A1"].font=Font(name="Pretendard",bold=True,size=18,color=NAVY)
ws4["A2"]="원칙: 답변엔진 UI를 자동 조회하지 않는다(OpenAI·Perplexity 약관). 측정은 우리가 소유한 신호(서버 로그·GA4·GSC)와 정식 API 반복 측정으로만 한다. 현재 공식 홈페이지·서포터즈 모두 E 4항목이 미검증(분모 제외) 상태다."; ws4["A2"].font=Font(name="Pretendard",size=14,color="64748B"); ws4["A2"].alignment=wrap; ws4.merge_cells("A2:I2"); ws4.row_dimensions[2].height=48
hdr=["항목 (가중치)","레벨 정의 0 → 3","필요한 데이터","공식 홈페이지 (viewclinic.com, nginx 직접 호스팅)","서포터즈 사이트 (Vercel 정적)","연결 방법 (단계)","접근 권한 · 담당","주기 · 산출물","현재 → 목표"]
for i,h in enumerate(hdr,1):
c=ws4.cell(row=4,column=i,value=h); c.font=hf; c.fill=fill(NAVY); c.alignment=center; c.border=bd
E_ROWS=[
("E1 AI 크롤러 로그 · IP 검증 (3)",
"0 로그 접근 불가 → 1 로그 있음·미분석 → 2 UA 기준 집계 → 3 IP 검증 히트 집계",
"웹서버 접근 로그(요청 시각·URL·User-Agent·원격 IP·상태코드). AI 봇 UA: GPTBot, OAI-SearchBot, ChatGPT-User, PerplexityBot, ClaudeBot, Claude-SearchBot, Google-Extended, Applebot-Extended, Bingbot, Amazonbot, CCBot, Bytespider",
"nginx access.log. 병원 호스팅 업체(또는 내부 IT)에서 로그 파일 또는 로그 전송(syslog/S3) 설정 필요. Cloudflare 미사용이라 AI Crawl Control 대시보드 없음",
"Vercel 프로젝트 → Settings → Log Drains(Pro 플랜 이상) 또는 Observability → Logs. 드레인 대상: Supabase Edge Function(수신 엔드포인트) → crawler_hits 테이블. 무료 플랜이면 Vercel 대시보드 로그 수동 내보내기로 시작",
"1) 로그 수집 경로 확보 2) UA로 1차 필터 3) 원격 IP를 공개 IP 대역과 대조해 위조 제거: OpenAI gptbot.json·searchbot.json, Google googlebot.json·special-crawlers.json, Perplexity perplexitybot.json, Bing bingbot.json, Anthropic 공개 IP 문서, 나머지는 역방향 DNS 4) 봇별·URL별 일별 히트 집계 5) 대시보드(/discovery 리포트 E 카드)에 표시. 참조 구현: ADO2 009. AEO-GEO/06_skill-snapshot/scripts/bot_ip_verify.py",
"공식: 병원 호스팅 관리자 계정 (병원 IT 또는 대행사). 서포터즈: INFINITH Vercel 팀(o2odev) + Supabase 서비스 키",
"일별 자동 집계, 주간 리포트. 산출물: crawler_hits 테이블, 봇별 방문 추이 차트, 첫 방문일(TTFC)",
"공식 미검증 → 2(UA 집계)까지 로그만 받으면 즉시, 3은 IP 검증 스크립트 연결 후. 서포터즈 미검증 → 발행과 동시에 3"),
("E2 GA4 AI 유입 채널 (3)",
"0 GA4 없음 → 1 GA4 있음·AI 채널 없음 → 2 기본 AI Assistant 채널 → 3 커스텀 채널(Perplexity 포함)",
"GA4 속성의 세션 소스/매체·리퍼러. 리퍼러 도메인: chatgpt.com, chat.openai.com, perplexity.ai, gemini.google.com, bard.google.com, copilot.microsoft.com, bing.com/chat, claude.ai, you.com, phind.com, 그리고 UTM(utm_source=chatgpt 등)",
"홈페이지에 GA4 태그가 있는지부터 확인(2026-08 실측 시 오너 접근 불가로 미검증). 있으면 관리 → 데이터 표시 → 채널 그룹에서 커스텀 채널 추가. 없으면 GTM으로 GA4 설치 후 동일",
"Astro 레이아웃에 GA4(gtag.js) 삽입 + 같은 커스텀 채널 그룹. 정적 사이트라 서버사이드 태깅 불필요. 서포터즈 GA4 속성은 병원 GA4 계정 안에 별도 속성으로 두어 병원이 소유",
"1) GA4 속성 생성 또는 접근 확보 2) 관리 → 채널 그룹 → 새 채널 그룹 생성 3) 채널 'AI Assistant' 규칙: 세션 소스가 정규식 (chatgpt|openai|perplexity|gemini|bard|copilot|bing\\.com/chat|claude|you\\.com|phind) 와 일치 4) 채널 우선순위를 Referral 위로 5) 탐색 보고서에 AI 채널 세션·랜딩 페이지·전환 추가 6) 서포터즈→병원 상담 예약 링크에 utm_source=viewsupporters 부착해 귀속",
"공식: 병원 GA4 관리자(편집자 권한). 서포터즈: INFINITH가 속성 생성 후 병원 계정에 관리자 권한 부여",
"실시간 수집, 월간 리포트. 산출물: AI 채널 세션 수·전환 수(하한값), 랜딩 페이지 순위. 주의: AI 유입의 35~70%가 Direct로 유실되므로 하한값으로만 보고",
"공식 미검증 → 3 (GA4 접근 확보 후 30분 작업). 서포터즈 미검증 → 발행 시 3"),
("E3 GSC AI Overviews 노출 (2)",
"0 GSC 미등록 → 1 등록만 → 2 데이터 확인 → 3 월간 추적",
"Google Search Console 실적 보고서의 검색 유형/검색 결과 모양 필터(AI 개요·AI 모드 노출·클릭). Bing Webmaster Tools(A6)와 함께 등록",
"search.google.com/search-console에 viewclinic.com 도메인 속성 등록. DNS TXT 인증은 도메인 관리자(병원)가 필요. 이미 등록돼 있을 수 있으니 오너 확인 먼저",
"서포터즈 도메인 확정 후 도메인 속성 등록(vercel.app 임시 도메인은 등록 불가 아님이나 발행 도메인으로 다시 등록해야 하므로 도메인 확정 후 진행). Vercel DNS면 TXT 추가 1분",
"1) GSC 도메인 속성 추가 → DNS TXT 인증 2) sitemap.xml 제출 3) 실적 보고서에서 '검색 결과 모양' 또는 '검색 유형' 필터로 AI 개요 노출 확인 4) 월 1회 스냅샷 저장(노출·클릭·평균 게재순위) 5) Bing 웹마스터 도구에 GSC 가져오기로 동시 등록(A6 해소) 6) IndexNow 키 설정",
"공식: 도메인 DNS 관리자(병원 또는 가비아 등 등록 대행). 서포터즈: INFINITH(Vercel DNS)",
"월 1회 수동 스냅샷. 산출물: AI 개요 노출 페이지 목록, 월별 추이표",
"공식 미검증 → 2는 등록 직후, 3은 3개월 누적 후. 서포터즈 미검증 → 도메인 확정 후 등록"),
("E4 기준선 스팟체크 (2)",
"0 없음 → 1 단발 조회만 → 2 반복 측정 1회 → 3 분기 반복 측정",
"질문 세트(QB 120문항 또는 핵심 10문항) × 엔진(OpenAI Responses API+web_search 서울 고정, Perplexity sonar) × 반복 5~7회 → 언급 여부·인용 URL·순위. 결과는 언급률 + 신뢰구간으로만 보고",
"이미 1회 실시(2026-09-01, 120문항 × 2엔진). 현재 레벨 2에 해당하나 리포트 원본이 INFINITH 측에 있어 병원 기준 미검증 처리. 분기 반복으로 3",
"동일 문항으로 발행 4주·8주 후 재측정. 서포터즈 도메인이 인용 URL에 등장하는지가 핵심 지표",
"1) scripts/run_question_bank_openai.py 로 OpenAI·Perplexity 반복 실행(JSONL 재개 가능) 2) scripts/merge_qb_results.py 로 통합표 3) scripts/export_qb_excel.py 로 엑셀 4) 브랜드/논브랜드 언급률·95% 신뢰구간 산출 5) 인용 도메인 빈도표에 viewsupporters 도메인 추가 6) 분기마다 반복해 추이 저장",
"INFINITH (OPENAI_API_KEY·PERPLEXITY_API_KEY 회사 계정). 병원 접근 불필요",
"분기 1회(발행 초기에는 4주·8주). 산출물: Viewclinic_QB_Results_*.xlsx, 언급률 추이, 인용 URL 목록",
"공식 미검증 → 2 즉시 인정 가능(리포트 첨부), 3은 다음 분기 재측정 후. 서포터즈 동일"),
]
for r,row in enumerate(E_ROWS,5):
for i,v in enumerate(row,1):
c=ws4.cell(row=r,column=i,value=v); c.font=bb if i==1 else bf; c.border=bd; c.alignment=wrap
ws4.row_dimensions[r].height=330
for i,w in enumerate([26,34,48,50,50,70,36,40,36],1): ws4.column_dimensions[get_column_letter(i)].width=w
ws4.freeze_panes="B5"
r=len(E_ROWS)+6
extra=[("공통 전제","서버 로그·GA4·GSC는 '오너가 소유한 신호'다. 병원이 계정 권한을 주지 않으면 공식 홈페이지 E는 계속 미검증이다. 서포터즈 사이트는 INFINITH가 인프라를 소유하므로 발행과 동시에 E 4항목을 모두 채울 수 있고, 이것이 서포터즈 사이트가 측정 장치 역할을 한다는 뜻이다."),
("IP 대역 출처","OpenAI: openai.com/gptbot.json, openai.com/searchbot.json, openai.com/chatgpt-user.json · Google: developers.google.com/search/apis/ipranges/googlebot.json, special-crawlers.json, user-triggered-fetchers.json · Perplexity: perplexity.com/perplexitybot.json · Bing: bing.com/toolbox/bingbot.json · Anthropic: 공식 문서의 공개 IP 목록 · 나머지 봇: 역방향 DNS + 정방향 재확인"),
("데이터 보관","crawler_hits(봇·IP·URL·시각·검증여부), ga4_ai_sessions(일·채널·랜딩·전환), gsc_ai_snapshot(월·페이지·노출·클릭), qb_runs(분기·문항·엔진·언급·인용URL). 모두 Supabase에 두고 /discovery/:id 리포트 E 카드가 읽는다."),
("보고 규칙","단발 조회 금지. 언급률은 신뢰구간과 함께. 크롤러 히트는 IP 검증분만. GA4 AI 유입은 하한값 표기. 스키마·콘텐츠 변경과 지표 변화의 인과를 단정하지 않는다.")]
for k,v in extra:
c=ws4.cell(row=r,column=1,value=k); c.font=bb; c.border=bd; c.alignment=wrap
c=ws4.cell(row=r,column=2,value=v); c.font=bf; c.border=bd; c.alignment=wrap; ws4.merge_cells(start_row=r,start_column=2,end_row=r,end_column=9); ws4.row_dimensions[r].height=64; r+=1
wb.save(OUT); print("saved",OUT, "official",S_off[3],S_off[4],"prod",S_prod[3],S_prod[4],"idx",S_fin[3],S_fin[4])