전국 지역별 펜션 키워드 데이터셋 + 엑셀 산출

기획 변경: 군산 단일 지역 → 전국 지역별.

- 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>
This commit is contained in:
hbyang 2026-09-11 17:04:40 +09:00
parent 25f2cc871d
commit df8ce5f117
7 changed files with 86794 additions and 2 deletions

View File

@ -64,7 +64,27 @@ OPENAI_EMBEDDING_MODEL=text-embedding-3-small
"고군산군도 오션뷰" 설정으로는 상위 매칭이 전부 `오션뷰 / 고군산군도` 로 나왔고, "고군산군도 오션뷰" 설정으로는 상위 매칭이 전부 `오션뷰 / 고군산군도` 로 나왔고,
실제 값(원도심 신흥동, 독채 2동)으로 고치자 `군산 원도심 독채펜션 / 군산 독채스테이` 로 바뀌었다. 실제 값(원도심 신흥동, 독채 2동)으로 고치자 `군산 원도심 독채펜션 / 군산 독채스테이` 로 바뀌었다.
## 데이터셋 ## 전국 지역별 데이터셋 (기획 변경분)
`data/전국_펜션_SEO_AEO_키워드.xlsx` — 전국 54개 펜션 수요 지역 × **7,138건**.
`npm run dataset:nationwide` 로 재생성한다 (`data/regions.json` → JSON → 엑셀).
시트 4개: `키워드` / `지역마스터` / `지역별요약` / `사용가이드`
**조합 폭발을 하지 않았다.** 군산 단일 지역 974건을 54개에 곱하면 5만 건이 되는데,
단일 지역 검증에서 저장분의 89%가 한 번도 쓰이지 않았다. 지역당 ~110건으로 눌렀다.
**지역 성격이 시설 키워드를 결정한다.** `regions.json``type`(해변·산간·호수·강변·도심·섬·계곡)에
따라 유효한 시설만 전개한다 — 평창·무주에는 오션뷰 키워드가 0건, 태안·거제에는 산뷰가 0건이다.
**티어** — 주력 568 / 보조 3,816 / 롱테일 1,836 / 태그 918.
주력은 페이지당 1개만 쓰는 대표 키워드 후보다.
⚠ **이 키워드는 검색 패턴 생성물이지 실제 검색 데이터가 아니다.**
엑셀의 `월간검색수`·`경쟁도` 열은 비워 두었다. 네이버 검색광고 키워드도구로 채운 뒤
월 10 미만을 걷어내야 실제로 쓸 수 있다.
## 데이터셋 (군산 단일 지역 · 매칭 엔진용)
`data/gunsan-pension-keywords.json` — "군산 펜션" 주제로 직접 작성한 1,000건. `data/gunsan-pension-keywords.json` — "군산 펜션" 주제로 직접 작성한 1,000건.
실제 군산 지명(선유도·고군산군도·새만금·은파호수공원·경암동 철길마을 …)과 실제 군산 지명(선유도·고군산군도·새만금·은파호수공원·경암동 철길마을 …)과

File diff suppressed because it is too large Load Diff

798
data/regions.json Normal file
View File

@ -0,0 +1,798 @@
{
"note": "전국 펜션 수요 지역 마스터. type 은 그 지역에서 유효한 시설 키워드를 결정한다 (해변→오션뷰, 산간→계곡뷰 등). spots 는 실제 대표 관광지. aliases 는 같은 지역을 가리키는 다른 검색 표기 (예: 대천/보령).",
"regions": [
{
"sido": "경기",
"name": "가평",
"key": "kr.gyeonggi.gapyeong",
"type": [
"호수",
"산간"
],
"spots": [
"남이섬",
"쁘띠프랑스",
"아침고요수목원",
"자라섬",
"청평호"
]
},
{
"sido": "경기",
"name": "양평",
"key": "kr.gyeonggi.yangpyeong",
"type": [
"강변",
"산간"
],
"spots": [
"두물머리",
"세미원",
"용문사"
]
},
{
"sido": "경기",
"name": "포천",
"key": "kr.gyeonggi.pocheon",
"type": [
"호수",
"산간"
],
"spots": [
"산정호수",
"포천아트밸리",
"허브아일랜드"
]
},
{
"sido": "경기",
"name": "파주",
"key": "kr.gyeonggi.paju",
"type": [
"도심",
"강변"
],
"spots": [
"헤이리예술마을",
"임진각",
"프로방스마을"
]
},
{
"sido": "인천",
"name": "강화",
"key": "kr.incheon.ganghwa",
"type": [
"해변",
"산간"
],
"spots": [
"마니산",
"동막해변",
"강화고인돌",
"전등사"
]
},
{
"sido": "인천",
"name": "을왕리",
"key": "kr.incheon.yeongjong",
"type": [
"해변"
],
"spots": [
"을왕리해수욕장",
"무의도",
"하나개해수욕장"
],
"aliases": [
"영종도"
]
},
{
"sido": "강원",
"name": "춘천",
"key": "kr.gangwon.chuncheon",
"type": [
"호수",
"도심"
],
"spots": [
"소양강스카이워크",
"김유정역",
"의암호",
"남이섬"
]
},
{
"sido": "강원",
"name": "홍천",
"key": "kr.gangwon.hongcheon",
"type": [
"산간",
"계곡"
],
"spots": [
"비발디파크",
"은행나무숲",
"홍천강"
]
},
{
"sido": "강원",
"name": "인제",
"key": "kr.gangwon.inje",
"type": [
"산간",
"계곡"
],
"spots": [
"자작나무숲",
"내린천",
"백담사"
]
},
{
"sido": "강원",
"name": "평창",
"key": "kr.gangwon.pyeongchang",
"type": [
"산간"
],
"spots": [
"대관령",
"오대산",
"월정사",
"알펜시아",
"양떼목장"
]
},
{
"sido": "강원",
"name": "정선",
"key": "kr.gangwon.jeongseon",
"type": [
"산간",
"계곡"
],
"spots": [
"하이원리조트",
"레일바이크",
"병방치스카이워크"
]
},
{
"sido": "강원",
"name": "강릉",
"key": "kr.gangwon.gangneung",
"type": [
"해변",
"도심"
],
"spots": [
"경포대",
"안목해변",
"정동진",
"주문진",
"오죽헌"
]
},
{
"sido": "강원",
"name": "속초",
"key": "kr.gangwon.sokcho",
"type": [
"해변",
"산간"
],
"spots": [
"설악산",
"대포항",
"영금정",
"아바이마을",
"속초해수욕장"
]
},
{
"sido": "강원",
"name": "양양",
"key": "kr.gangwon.yangyang",
"type": [
"해변"
],
"spots": [
"낙산사",
"죽도해변",
"하조대",
"서피비치",
"인구해변"
]
},
{
"sido": "강원",
"name": "고성",
"key": "kr.gangwon.goseong",
"type": [
"해변"
],
"spots": [
"송지호",
"화진포",
"통일전망대",
"백섬해상전망대"
]
},
{
"sido": "강원",
"name": "동해",
"key": "kr.gangwon.donghae",
"type": [
"해변"
],
"spots": [
"추암촛대바위",
"망상해수욕장",
"무릉계곡"
]
},
{
"sido": "강원",
"name": "삼척",
"key": "kr.gangwon.samcheok",
"type": [
"해변",
"계곡"
],
"spots": [
"장호항",
"환선굴",
"맹방해변"
]
},
{
"sido": "강원",
"name": "태백",
"key": "kr.gangwon.taebaek",
"type": [
"산간"
],
"spots": [
"태백산",
"검룡소",
"365세이프타운"
]
},
{
"sido": "충북",
"name": "단양",
"key": "kr.chungbuk.danyang",
"type": [
"산간",
"강변"
],
"spots": [
"도담삼봉",
"만천하스카이워크",
"고수동굴",
"단양강잔도"
]
},
{
"sido": "충북",
"name": "제천",
"key": "kr.chungbuk.jecheon",
"type": [
"호수",
"산간"
],
"spots": [
"청풍호",
"의림지",
"배론성지"
]
},
{
"sido": "충북",
"name": "충주",
"key": "kr.chungbuk.chungju",
"type": [
"호수",
"산간"
],
"spots": [
"탄금대",
"수안보온천",
"충주호"
]
},
{
"sido": "충북",
"name": "괴산",
"key": "kr.chungbuk.goesan",
"type": [
"산간",
"계곡"
],
"spots": [
"산막이옛길",
"화양구곡"
]
},
{
"sido": "충남",
"name": "태안",
"key": "kr.chungnam.taean",
"type": [
"해변"
],
"spots": [
"만리포해수욕장",
"꽃지해변",
"안면도",
"신두리해안사구",
"청포대"
]
},
{
"sido": "충남",
"name": "대천",
"key": "kr.chungnam.boryeong",
"type": [
"해변"
],
"spots": [
"대천해수욕장",
"무창포",
"죽도상화원",
"성주산"
],
"aliases": [
"보령"
]
},
{
"sido": "충남",
"name": "서산",
"key": "kr.chungnam.seosan",
"type": [
"해변",
"도심"
],
"spots": [
"해미읍성",
"간월암",
"개심사"
]
},
{
"sido": "충남",
"name": "공주",
"key": "kr.chungnam.gongju",
"type": [
"도심",
"강변"
],
"spots": [
"공산성",
"무령왕릉",
"마곡사"
]
},
{
"sido": "충남",
"name": "부여",
"key": "kr.chungnam.buyeo",
"type": [
"도심",
"강변"
],
"spots": [
"궁남지",
"부소산성",
"백제문화단지"
]
},
{
"sido": "전북",
"name": "군산",
"key": "kr.jeonbuk.gunsan",
"type": [
"해변",
"도심"
],
"spots": [
"선유도",
"고군산군도",
"말랭이마을",
"경암동 철길마을",
"근대역사박물관",
"은파호수공원",
"새만금"
]
},
{
"sido": "전북",
"name": "변산",
"key": "kr.jeonbuk.buan",
"type": [
"해변",
"산간"
],
"spots": [
"채석강",
"변산해수욕장",
"내소사",
"격포항"
],
"aliases": [
"부안"
]
},
{
"sido": "전북",
"name": "전주",
"key": "kr.jeonbuk.jeonju",
"type": [
"도심"
],
"spots": [
"전주한옥마을",
"경기전",
"전동성당",
"남부시장"
]
},
{
"sido": "전북",
"name": "무주",
"key": "kr.jeonbuk.muju",
"type": [
"산간",
"계곡"
],
"spots": [
"덕유산",
"무주리조트",
"반디랜드",
"구천동계곡"
]
},
{
"sido": "전북",
"name": "남원",
"key": "kr.jeonbuk.namwon",
"type": [
"도심",
"산간"
],
"spots": [
"광한루원",
"지리산",
"춘향테마파크"
]
},
{
"sido": "전남",
"name": "여수",
"key": "kr.jeonnam.yeosu",
"type": [
"해변",
"도심"
],
"spots": [
"오동도",
"돌산대교",
"향일암",
"여수해상케이블카",
"낭만포차"
]
},
{
"sido": "전남",
"name": "순천",
"key": "kr.jeonnam.suncheon",
"type": [
"도심",
"습지"
],
"spots": [
"순천만습지",
"낙안읍성",
"순천만국가정원"
]
},
{
"sido": "전남",
"name": "담양",
"key": "kr.jeonnam.damyang",
"type": [
"도심",
"계곡"
],
"spots": [
"죽녹원",
"메타세쿼이아길",
"소쇄원"
]
},
{
"sido": "전남",
"name": "구례",
"key": "kr.jeonnam.gurye",
"type": [
"산간",
"계곡"
],
"spots": [
"지리산",
"화엄사",
"섬진강",
"사성암"
]
},
{
"sido": "전남",
"name": "해남",
"key": "kr.jeonnam.haenam",
"type": [
"해변",
"산간"
],
"spots": [
"땅끝마을",
"두륜산",
"대흥사"
]
},
{
"sido": "전남",
"name": "완도",
"key": "kr.jeonnam.wando",
"type": [
"해변",
"섬"
],
"spots": [
"청산도",
"명사십리해수욕장",
"완도타워"
]
},
{
"sido": "전남",
"name": "보성",
"key": "kr.jeonnam.boseong",
"type": [
"도심",
"해변"
],
"spots": [
"보성녹차밭",
"율포해수욕장"
]
},
{
"sido": "경북",
"name": "경주",
"key": "kr.gyeongbuk.gyeongju",
"type": [
"도심",
"호수"
],
"spots": [
"불국사",
"첨성대",
"황리단길",
"보문단지",
"동궁과월지"
]
},
{
"sido": "경북",
"name": "포항",
"key": "kr.gyeongbuk.pohang",
"type": [
"해변",
"도심"
],
"spots": [
"호미곶",
"영일대해수욕장",
"죽도시장",
"스페이스워크"
]
},
{
"sido": "경북",
"name": "안동",
"key": "kr.gyeongbuk.andong",
"type": [
"도심",
"강변"
],
"spots": [
"하회마을",
"월영교",
"도산서원"
]
},
{
"sido": "경북",
"name": "영덕",
"key": "kr.gyeongbuk.yeongdeok",
"type": [
"해변"
],
"spots": [
"강구항",
"블루로드",
"고래불해수욕장"
]
},
{
"sido": "경북",
"name": "울진",
"key": "kr.gyeongbuk.uljin",
"type": [
"해변",
"산간"
],
"spots": [
"죽변항",
"덕구온천",
"성류굴",
"후포항"
]
},
{
"sido": "경북",
"name": "문경",
"key": "kr.gyeongbuk.mungyeong",
"type": [
"산간",
"계곡"
],
"spots": [
"문경새재",
"단산모노레일",
"에코랄라"
]
},
{
"sido": "경남",
"name": "거제",
"key": "kr.gyeongnam.geoje",
"type": [
"해변",
"섬"
],
"spots": [
"외도보타니아",
"바람의언덕",
"학동몽돌해변",
"매미성",
"windy hill"
]
},
{
"sido": "경남",
"name": "통영",
"key": "kr.gyeongnam.tongyeong",
"type": [
"해변",
"섬"
],
"spots": [
"동피랑벽화마을",
"통영케이블카",
"미륵산",
"한산도",
"장사도"
]
},
{
"sido": "경남",
"name": "남해",
"key": "kr.gyeongnam.namhae",
"type": [
"해변",
"섬"
],
"spots": [
"다랭이마을",
"독일마을",
"상주은모래비치",
"보리암"
]
},
{
"sido": "경남",
"name": "하동",
"key": "kr.gyeongnam.hadong",
"type": [
"산간",
"강변"
],
"spots": [
"화개장터",
"쌍계사",
"섬진강",
"최참판댁"
]
},
{
"sido": "경남",
"name": "사천",
"key": "kr.gyeongnam.sacheon",
"type": [
"해변"
],
"spots": [
"삼천포대교",
"사천케이블카",
"실안노을길"
]
},
{
"sido": "경남",
"name": "산청",
"key": "kr.gyeongnam.sancheong",
"type": [
"산간",
"계곡"
],
"spots": [
"지리산",
"동의보감촌",
"대원사계곡"
]
},
{
"sido": "부산",
"name": "기장",
"key": "kr.busan.gijang",
"type": [
"해변"
],
"spots": [
"해동용궁사",
"일광해수욕장",
"아난티코브",
"죽성성당"
]
},
{
"sido": "제주",
"name": "제주시",
"key": "kr.jeju.jejusi",
"type": [
"해변",
"도심"
],
"spots": [
"애월",
"함덕해수욕장",
"협재해수욕장",
"이호테우",
"한림공원"
]
},
{
"sido": "제주",
"name": "서귀포",
"key": "kr.jeju.seogwipo",
"type": [
"해변",
"산간"
],
"spots": [
"중문색달해변",
"성산일출봉",
"쇠소깍",
"천지연폭포",
"우도"
]
}
]
}

Binary file not shown.

View File

@ -17,7 +17,8 @@
"dataset:build": "node scripts/build-dataset.mjs", "dataset:build": "node scripts/build-dataset.mjs",
"dataset:ingest": "tsx scripts/ingest-dataset.ts", "dataset:ingest": "tsx scripts/ingest-dataset.ts",
"dataset:purge": "tsx scripts/purge-nondataset.ts", "dataset:purge": "tsx scripts/purge-nondataset.ts",
"dataset:import-related": "tsx scripts/import-related.ts" "dataset:import-related": "tsx scripts/import-related.ts",
"dataset:nationwide": "node scripts/build-nationwide-dataset.mjs && python3 scripts/export-xlsx.py"
}, },
"dependencies": { "dependencies": {
"@huggingface/transformers": "^4.2.0", "@huggingface/transformers": "^4.2.0",

View File

@ -0,0 +1,153 @@
/**
* 전국 지역별 펜션 SEO/AEO 키워드 데이터셋.
* node scripts/build-nationwide-dataset.mjs data/nationwide-pension-keywords.json
*
* 설계 원칙
* · 조합 폭발을 하지 않는다. 군산 단일 지역 974건을 54 지역에 곱하면 5 건이 되고
* 대부분 검색량 0 된다 (실측: 저장분의 89% 미사용).
* · 지역 성격(해변/산간/호수/도심/) 맞는 시설 키워드만 전개한다.
* 산간 지역에 '오션뷰 펜션' 만들지 않는다.
* · 티어를 매겨 주력/보조/롱테일을 구분한다. SEO 페이지당 주력 1개다.
*/
import { readFileSync, writeFileSync } from 'node:fs';
const { regions } = JSON.parse(readFileSync('data/regions.json', 'utf8'));
// ── 공통 어휘
const STAY = ['펜션', '숙소', '독채펜션', '풀빌라', '스파펜션', '애견펜션', '감성펜션', '글램핑', '독채'];
const INTENT = { 추천:'local', 예약:'transactional', 가격:'transactional', 후기:'informational',
순위:'informational', 저렴한곳:'local', 가성비:'local', 실시간예약:'transactional',
당일예약:'transactional', 특가:'transactional' };
const WITH = ['커플', '가족', '친구', '애견동반', '단체', '아이동반', '부모님', '4인', '6인', '2인'];
const VIBE = ['감성', '조용한', '분위기 좋은', '사진찍기 좋은', '인생샷', '깔끔한', '신축'];
const TRAVEL = ['1박2일', '2박3일', '주말여행', '뚜벅이 여행', '워케이션'];
// 지역 성격별 유효 시설 — 여기가 조합 폭발을 막는 장치다
const FEATURES_BY_TYPE = {
해변: ['오션뷰', '바다뷰', '노을뷰', '일출뷰', '해변 근처', '바다 보이는'],
: ['오션뷰', '바다뷰', '배타고 가는', '섬'],
산간: ['산뷰', '숲속', '불멍', '화로대', '벽난로', '단풍'],
계곡: ['계곡', '물놀이', '계곡뷰', '불멍'],
호수: ['호수뷰', '레이크뷰', '물놀이', '노을뷰'],
강변: ['강뷰', '리버뷰', '노을뷰'],
도심: ['역세권', '시내', '주차', '도보 여행'],
습지: ['자연', '산책'],
};
const FEATURES_COMMON = ['바베큐', '스파', '자쿠지', '수영장', '독채', '프라이빗', '복층', '테라스', '애견운동장', '넷플릭스'];
const SEASON_BY_TYPE = {
해변: ['여름휴가', '물놀이', '해수욕', '일출', '낙조'],
: ['여름휴가', '일출'],
산간: ['겨울', '단풍', '스키', '눈꽃'],
계곡: ['여름휴가', '물놀이', '단풍'],
호수: ['여름휴가', '단풍', '벚꽃'],
강변: ['벚꽃', '단풍'],
도심: ['벚꽃', '연말'],
습지: ['가을', '갈대'],
};
const SEASON_COMMON = ['겨울', '연말', '크리스마스', '주말', '성수기'];
const rows = [];
const seen = new Set();
const norm = (s) => s.normalize('NFKC').toLowerCase().replace(/\s+/g, '');
function add(region, keyword, { intent = 'local', kind = 'keyword', category, tier, relevance }) {
const k = keyword.replace(/\s+/g, ' ').trim();
const id = `${region.key}|${norm(k)}`;
if (!k || seen.has(id)) return;
seen.add(id);
rows.push({
sido: region.sido, region: region.name, regionKey: region.key,
regionType: region.type.join('·'),
keyword: k, kind, intent, category, tier,
relevance: Math.round(relevance * 100) / 100,
});
}
const uniq = (a) => [...new Set(a)];
for (const r of regions) {
const R = r.name;
const feats = uniq([...r.type.flatMap((t) => FEATURES_BY_TYPE[t] ?? []), ...FEATURES_COMMON]);
const seasons = uniq([...r.type.flatMap((t) => SEASON_BY_TYPE[t] ?? []), ...SEASON_COMMON]);
// T1 코어 — 주력 후보.
// 별칭(대천/보령 처럼 같은 지역의 다른 검색 표기)도 코어·의도 계층까지는 함께 전개한다.
// 전 계층에 곱하면 두 배가 되므로 상위 티어에만 적용한다.
const names = [R, ...(r.aliases ?? [])];
for (const N of names) {
add(r, `${N} 펜션`, { category: '코어', tier: '주력', relevance: N === R ? 0.98 : 0.94 });
add(r, `${N} 숙소`, { category: '코어', tier: '주력', relevance: N === R ? 0.96 : 0.92 });
for (const s of STAY.slice(2)) add(r, `${N} ${s}`, { category: '코어', tier: '주력', relevance: 0.9 });
}
// T2 의도 — 보조
for (const N of names)
for (const [m, it] of Object.entries(INTENT))
add(r, `${N} 펜션 ${m}`, { intent: it, category: '의도', tier: '보조', relevance: N === R ? 0.88 : 0.84 });
for (const s of ['독채펜션', '풀빌라', '애견펜션', '감성펜션'])
for (const m of ['추천', '예약', '가격', '후기'])
add(r, `${R} ${s} ${m}`, { intent: INTENT[m], category: '의도', tier: '보조', relevance: 0.8 });
// T3 동반자
for (const w of WITH) {
add(r, `${R} ${w} 펜션`, { category: '동반자', tier: '보조', relevance: 0.85 });
add(r, `${R} ${w} 펜션 추천`, { category: '동반자', tier: '롱테일', relevance: 0.7 });
}
// T4 시설 — 지역 성격에 맞는 것만
for (const f of feats) {
add(r, `${R} ${f} 펜션`, { category: '시설', tier: '보조', relevance: 0.83 });
}
for (const f of feats.slice(0, 6))
add(r, `${R} 커플 ${f} 펜션`, { category: '시설', tier: '롱테일', relevance: 0.55 });
// T5 시즌
for (const s of seasons) add(r, `${R} ${s} 펜션`, { category: '시즌', tier: '보조', relevance: 0.76 });
// T6 관광지 앵커 — 지역 고유
for (const sp of r.spots) {
add(r, `${sp} 근처 펜션`, { category: '관광지', tier: '보조', relevance: 0.84 });
add(r, `${sp} 근처 숙소`, { category: '관광지', tier: '보조', relevance: 0.81 });
}
// T7 분위기·여행형태
for (const v of VIBE) add(r, `${R} ${v} 숙소`, { category: '분위기', tier: '롱테일', relevance: 0.68 });
for (const t of TRAVEL) add(r, `${R} ${t} 숙소`, { category: '여행형태', tier: '롱테일', relevance: 0.66 });
// T8 질문형 (AEO)
const qs = [
[`${R} 펜션 어디가 좋아요`, 'informational', 0.72],
[`${R} 펜션 1박 얼마인가요`, 'transactional', 0.7],
[`${R} 애견동반 펜션 있나요`, 'informational', 0.68],
[`${R} 펜션 바베큐 가능한가요`, 'informational', 0.67],
[`${R} 여행 몇박이 좋을까요`, 'informational', 0.6],
[`${R} 펜션 성수기 언제인가요`, 'informational', 0.58],
];
for (const [q, it, rel] of qs) add(r, q, { intent: it, category: '질문형', tier: '롱테일', relevance: rel });
// T9 태그 (칩 UI)
for (const t of uniq([...feats.slice(0, 8), ...WITH.slice(0, 5), ...VIBE.slice(0, 4)]))
add(r, t, { kind: 'tag', category: '태그', tier: '태그', relevance: 0.5 });
}
// 광역 단위 롤업
const sidoList = uniq(regions.map((x) => x.sido));
for (const sido of sidoList) {
const pseudo = { sido, name: sido, key: `rollup.${sido}`, type: [] };
for (const s of ['펜션', '숙소', '독채펜션', '풀빌라', '애견펜션'])
add(pseudo, `${sido} ${s}`, { category: '광역', tier: '주력', relevance: 0.92 });
for (const m of ['추천', '예약', '가격', '후기'])
add(pseudo, `${sido} 펜션 ${m}`, { intent: INTENT[m], category: '광역', tier: '보조', relevance: 0.85 });
}
writeFileSync('data/nationwide-pension-keywords.json',
JSON.stringify({ topic: '전국 지역별 펜션', locale: 'ko-KR',
generatedBy: 'region master × search-pattern expansion (region-type aware)',
regionCount: regions.length, count: rows.length, items: rows }, null, 2) + '\n');
const by = (f) => rows.reduce((a, r) => (a[r[f]] = (a[r[f]] ?? 0) + 1, a), {});
console.log(`✅ data/nationwide-pension-keywords.json ${rows.length}건 / ${regions.length}개 지역`);
console.log(` 지역당 평균 ${Math.round(rows.length / (regions.length + sidoList.length))}`);
console.log(' 티어:', by('tier'));
console.log(' 카테고리:', by('category'));

155
scripts/export-xlsx.py Normal file
View File

@ -0,0 +1,155 @@
# -*- 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)}')