# -*- coding: utf-8 -*- """전국 펜션 키워드 데이터셋 → 엑셀. python3 scripts/export-xlsx.py 검색량·경쟁도 열은 비워 둔다 — 네이버 검색광고 키워드도구에서 받아 채우는 자리.""" import json, collections from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.utils import get_column_letter SRC = 'data/nationwide-pension-keywords.json' OUT = 'data/전국_펜션_SEO_AEO_키워드.xlsx' INK = '1F2A2B' ACC = '0D6A60' HEAD = PatternFill('solid', fgColor='0D6A60') BAND = PatternFill('solid', fgColor='F1F5F4') TIER = {'주력': 'DFF0EC', '보조': 'FFFFFF', '롱테일': 'F7F7F5', '태그': 'F6EAD2'} THIN = Side(style='thin', color='D5DCDB') BOX = Border(left=THIN, right=THIN, top=THIN, bottom=THIN) SLOT = { '코어': '메인 페이지 (주력)', '광역': '광역 랜딩', '의도': '메인 / 예약 페이지', '동반자': '객실 페이지', '시설': '시설 페이지', '관광지': '주변 여행 페이지', '시즌': '블로그 · 프로모션', '분위기': '블로그 · 소개', '여행형태': '블로그 · 코스', '질문형': 'FAQ (AEO · FAQPage)', '태그': '필터 UI (SEO 아님)', } data = json.load(open(SRC, encoding='utf-8')) items = data['items'] regions = json.load(open('data/regions.json', encoding='utf-8'))['regions'] wb = Workbook() def style_header(ws, ncols, height=22): for c in range(1, ncols + 1): cell = ws.cell(row=1, column=c) cell.fill = HEAD cell.font = Font(bold=True, color='FFFFFF', size=10) cell.alignment = Alignment(horizontal='center', vertical='center') cell.border = BOX ws.row_dimensions[1].height = height ws.freeze_panes = 'A2' ws.auto_filter.ref = f'A1:{get_column_letter(ncols)}{ws.max_row}' def widths(ws, ws_widths): for i, w in enumerate(ws_widths, start=1): ws.column_dimensions[get_column_letter(i)].width = w # ────────────────────────────────── 1. 키워드 ws = wb.active ws.title = '키워드' cols = ['시도', '지역', '지역키', '지역성격', '키워드', '종류', '의도', '카테고리', '티어', '관련도', '월간검색수', '경쟁도', '추천 배치', '비고'] ws.append(cols) for it in items: ws.append([ it['sido'], it['region'], it['regionKey'], it['regionType'], it['keyword'], it['kind'], it['intent'], it['category'], it['tier'], it['relevance'], None, None, SLOT.get(it['category'], ''), None, ]) for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=len(cols)): fill = PatternFill('solid', fgColor=TIER.get(row[8].value, 'FFFFFF')) for c in row: c.font = Font(size=10, color=INK) c.border = BOX c.fill = fill row[9].number_format = '0.00' row[10].number_format = '#,##0' row[4].font = Font(size=10, bold=True, color=INK) style_header(ws, len(cols)) widths(ws, [8, 12, 24, 14, 30, 8, 14, 10, 9, 9, 12, 10, 22, 16]) # ────────────────────────────────── 2. 지역 마스터 ws = wb.create_sheet('지역마스터') ws.append(['시도', '지역', '지역키', '지역성격', '대표 관광지 (앵커)', '키워드 수']) cnt = collections.Counter(i['regionKey'] for i in items) for r in regions: ws.append([r['sido'], r['name'], r['key'], '·'.join(r['type']), ', '.join(r['spots']), cnt.get(r['key'], 0)]) for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=6): for c in row: c.font = Font(size=10, color=INK); c.border = BOX c.alignment = Alignment(vertical='center', wrap_text=(c.column == 5)) style_header(ws, 6) widths(ws, [8, 14, 26, 14, 70, 10]) # ────────────────────────────────── 3. 지역별 요약 ws = wb.create_sheet('지역별요약') tiers = ['주력', '보조', '롱테일', '태그'] ws.append(['시도', '지역'] + tiers + ['합계']) per = collections.defaultdict(collections.Counter) meta = {} for i in items: per[i['regionKey']][i['tier']] += 1 meta[i['regionKey']] = (i['sido'], i['region']) for key, c in sorted(per.items(), key=lambda kv: (-sum(kv[1].values()))): sido, name = meta[key] ws.append([sido, name] + [c[t] for t in tiers] + [sum(c.values())]) for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=7): for c in row: c.font = Font(size=10, color=INK); c.border = BOX style_header(ws, 7) widths(ws, [8, 14, 9, 9, 10, 9, 9]) # ────────────────────────────────── 4. 사용 가이드 ws = wb.create_sheet('사용가이드') guide = [ ('이 파일은 무엇인가', ''), ('', f"전국 {data['regionCount']}개 펜션 수요 지역 × 검색 패턴으로 만든 SEO/AEO 키워드 후보 {len(items):,}건."), ('', '지역 성격(해변·산간·호수·도심·섬)에 맞는 시설 키워드만 전개했다. 산간 지역에 오션뷰 키워드는 없다.'), ('', ''), ('⚠ 반드시 먼저 읽을 것', ''), ('', '이 키워드는 검색 패턴으로 생성한 것이지 실제 검색 데이터가 아니다.'), ('', '네이버 검색광고 > 도구 > 키워드도구 에서 월간검색수를 받아 [월간검색수] 열을 채운 뒤'), ('', '월 10 미만은 걷어내야 한다. 앞선 단일 지역 검증에서 저장분의 89%가 한 번도 쓰이지 않았다.'), ('', ''), ('티어의 뜻', ''), ('주력', '페이지의 대표 키워드 후보. 한 페이지에 1개만 쓴다.'), ('보조', 'h2/h3 와 본문에 배치. 페이지당 3~5개.'), ('롱테일', '블로그·상세 페이지용. 검색량 확인 후 취사선택.'), ('태그', '사이트 필터 UI 용. SEO 키워드가 아니다.'), ('', ''), ('한 페이지에 몇 개를 넣나', ''), ('', 'title 1개 · h1 1개 · meta description 2~3개 · h2/h3 3~5개 · 본문 5~10개'), ('', 'meta keywords 태그는 쓰지 않는다 (구글은 2009년부터 랭킹에 반영하지 않는다).'), ('', '한 페이지에 주력을 여러 개 넣으면 주제가 희석돼 어느 것으로도 안 잡힌다.'), ('', ''), ('AEO (답변엔진)', ''), ('', '[카테고리=질문형] 행이 AEO 용이다. FAQPage 구조화 데이터로 8~15쌍 넣는다.'), ('', '답변은 2~3문장, 업체 정보에 근거한 사실만 쓴다.'), ('', ''), ('다음 단계', ''), ('', '1. 키워드도구로 [월간검색수]·[경쟁도] 채우기'), ('', '2. 월 10 미만 행 제거'), ('', '3. 관련도 높음 + 볼륨 중간 + 경쟁 낮음 조합을 우선 채택'), ('', '4. [추천 배치] 열대로 페이지에 배분'), ] ws.append(['항목', '내용']) for a, b in guide: ws.append([a, b]) for row in ws.iter_rows(min_row=2, max_row=ws.max_row, max_col=2): bold = bool(row[0].value) and not row[1].value row[0].font = Font(size=10, bold=True, color=ACC if bold else INK) row[1].font = Font(size=10, color=INK) row[1].alignment = Alignment(wrap_text=True, vertical='center') style_header(ws, 2) widths(ws, [22, 100]) wb.save(OUT) print(f'✅ {OUT}') print(f' 시트: ' + ', '.join(s.title for s in wb.worksheets)) print(f' 키워드 {len(items):,}행 / 지역 {len(regions)}개')