기획 변경: 군산 단일 지역 → 전국 지역별. - data/regions.json — 전국 54개 펜션 수요 지역 마스터 시도/지역/지역키/성격(해변·산간·호수·강변·도심·섬·계곡)/대표 관광지 202곳 복합 지명은 별칭으로 분리 (보령·대천 → 대천 + 별칭 보령) - scripts/build-nationwide-dataset.mjs — 지역 성격에 맞는 시설·시즌만 전개 평창·무주 오션뷰 0건, 태안·거제 산뷰 0건으로 검증 - scripts/export-xlsx.py — 4개 시트 엑셀 (키워드/지역마스터/지역별요약/사용가이드) - npm run dataset:nationwide 7,138건 / 지역당 평균 110건. 군산 974건을 54개 지역에 곱하면 5만 건이 되지만, 단일 지역 검증에서 저장분의 89%가 미사용이었으므로 티어를 매겨 눌렀다. 검색량·경쟁도 열은 비워 두었다 — 키워드도구로 채운 뒤 월 10 미만을 걷어내야 한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
156 lines
7.3 KiB
Python
156 lines
7.3 KiB
Python
# -*- 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)}개')
|