o2o-site-ontology/scripts/build-dataset.mjs
hbyang 25f2cc871d 스테이머뭄 매칭 품질 개선 4건
② 사전 보강 — 실측으로 찾은 어휘 공백을 메움
   - 인원: 2인·3인 추가 (4/6/10/20인만 있어 기준 2인 업체가 노릴 키워드가 없었다)
   - 단독 명사형: 군산 독채 / 스테이 / 풀빌라 (조합형만 있어 단독 검색을 놓쳤다)
   - 분위기 11종(감성·조용한·사진찍기 좋은·인생샷 …), 여행형태 9종(뚜벅이·1박2일 …)
   - scripts/import-related.ts — 검색광고 키워드도구 내려받기 병합 (파일 임포터)

③ 사전 오염 정리
   - source='llm' 27건 삭제 (강남 미용실·해운대 한식당이 군산 펜션 사전에 있었다)
   - 매칭 후보를 업체 업종으로 한정 + source IN ('dataset','manual') 만 조회
   - 적재 시 데이터셋에서 빠진 행 삭제 — upsert 만 하면 재빌드마다 누적된다 (974 → 1072)

④ 고객 언어 레인 (w=0.9)
   - hashtags + reviewSignals(원문 아닌 빈도 집계) 를 별도 레인으로
   - 사업자 표현보다 검색어에 가까우므로 유형 다음으로 높게 잡음
   - 스테이머뭄 데이터는 아직 없음 — 인스타는 로그인 월이라 스크래퍼가 채워야 함

⑤ 레인 토큰 중복 제거
   - '신흥동' 과 '신흥동 일본식가옥' 이 별개 원소라 같은 낱말이 두 번 실렸다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 14:09:36 +09:00

205 lines
12 KiB
JavaScript
Raw 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.

/**
* "군산 펜션" SEO/AEO 키워드·태그 데이터셋 1,000건 생성.
* node scripts/build-dataset.mjs → data/gunsan-pension-keywords.json
*
* 어휘는 실제 군산 지명·관광지·숙소 시설 용어로 구성했고,
* 패턴은 한국 로컬 숙박 검색에서 실제로 쓰이는 조합만 전개한다.
* 가치가 높은 순으로 방출하므로 1,000건에서 잘라도 상위 의도가 남는다.
*/
import { writeFileSync, mkdirSync } from 'node:fs';
// ──────────────────────────────────────────────── 어휘 (실제 군산 기반)
const REGION = '군산';
// 고군산군도·해안 권역
const ISLANDS = ['선유도', '무녀도', '장자도', '대장도', '신시도', '야미도', '고군산군도'];
// 시내·주요 권역
const AREAS = ['새만금', '비응항', '오식도', '은파호수공원', '월명동', '나운동', '수송동', '미룡동', '옥도면',
'군산 원도심', '신흥동', '영화동'];
// 관광지 (근처 숙소 검색의 앵커)
const SPOTS = [
'선유도해수욕장', '새만금방조제', '경암동 철길마을', '근대역사박물관', '초원사진관',
'이성당', '동국사', '진포해양테마공원', '신흥동 일본식가옥', '은파호수공원',
'월명공원', '금강하구둑', '철새조망대', '채만식문학관', '째보선창', '시간여행마을',
'말랭이마을', '군산 근대문화역사거리', '해망굴', '군산항 뜬다리부두',
];
// 숙소 유형
const STAY_CORE = ['펜션', '숙소', '풀빌라', '독채펜션', '스파펜션', '애견펜션', '감성펜션'];
const STAY_ALT = ['글램핑', '카라반', '캠핑장', '게스트하우스', '민박', '리조트', '한옥펜션', '촌집', '별장',
'독채스테이', '감성숙소', '스테이', '일본식가옥 숙소'];
// 검색 의도어
const INTENT_CORE = ['추천', '예약', '가격', '후기', '순위'];
const INTENT_MORE = ['저렴한곳', '가성비', '최저가', '실시간예약', '당일예약', '특가', '할인',
'위치', '주차', '전화번호', '체크인시간', '조식포함', '청소상태'];
// 동반자
const WITH_CORE = ['커플', '가족', '친구', '애견동반', '단체'];
const WITH_MORE = ['신혼', '아이동반', '유아동반', '부모님', '효도여행', '대학생', 'MT', '워크샵',
'회사', '태교여행', '혼자', '여자끼리', '2인', '3인', '4인', '6인', '10인', '20인'];
// 분위기·취향 — 감성 독채 스테이 계열에서 실제로 많이 쓰이는 수식어
const VIBE = ['감성', '조용한', '분위기 좋은', '예쁜', '사진찍기 좋은', '인생샷', '뷰맛집',
'깔끔한', '신축', '프라이빗한', '혼자 있기 좋은'];
// 여행 형태 — 숙소 검색은 '며칠/어떻게 다니는가'로도 갈린다
const TRAVEL = ['1박2일', '2박3일', '당일치기', '주말여행', '뚜벅이 여행', '혼행',
'워케이션', '무박', '한달살기'];
// 시설·특징
const FEAT_CORE = ['오션뷰', '바다뷰', '독채', '프라이빗', '스파', '자쿠지', '바베큐', '수영장'];
const FEAT_MORE = ['노을뷰', '일출뷰', '월풀', '온수풀', '야외수영장', '인피니티풀', '불멍', '화로대',
'넷플릭스', '빔프로젝터', '노래방', '파티룸', '복층', '테라스', '마당', '벽난로',
'애견운동장', '키즈룸', '트램폴린', '무료주차', '조식', '세미나실'];
// 시즌·행사
const SEASON = ['여름휴가', '물놀이', '해수욕', '겨울', '연말', '크리스마스', '신정', '설날', '추석',
'봄', '벚꽃', '가을', '단풍', '일출', '낙조', '불꽃놀이', '성수기', '비수기', '주말', '평일'];
const OCCASION = ['생일', '기념일', '결혼기념일', '프러포즈', '100일', '가족여행', '워크샵', '단합대회'];
// ──────────────────────────────────────────────── 방출
const rows = [];
const seen = new Set();
const INTENT_MAP = {
예약: 'transactional', 실시간예약: 'transactional', 당일예약: 'transactional',
가격: 'transactional', 최저가: 'transactional', 특가: 'transactional', 할인: 'transactional',
후기: 'informational', 순위: 'informational', 청소상태: 'informational', 체크인시간: 'informational',
};
const intentOf = (m) => INTENT_MAP[m] ?? 'local';
function add(keyword, { intent = 'local', kind = 'keyword', category, relevance }) {
const k = keyword.replace(/\s+/g, ' ').trim();
if (!k || seen.has(k)) return false;
seen.add(k);
rows.push({ keyword: k, intent, kind, category, relevance: Math.round(relevance * 100) / 100 });
return true;
}
// T1 — 코어: 지역 × 숙소유형 × 의도
for (const s of STAY_CORE) add(`${REGION} ${s}`, { category: '코어', relevance: 0.97 });
for (const s of STAY_CORE) for (const m of INTENT_CORE)
add(`${REGION} ${s} ${m}`, { intent: intentOf(m), category: '코어', relevance: 0.93 });
for (const s of STAY_ALT) add(`${REGION} ${s}`, { category: '코어', relevance: 0.86 });
for (const m of INTENT_MORE) add(`${REGION} 펜션 ${m}`, { intent: intentOf(m), category: '의도', relevance: 0.88 });
// 단독 명사형 — '군산 독채펜션' 만 있으면 '군산 독채' 검색을 놓친다
for (const n of ['독채', '스테이', '풀빌라', '민박', '한옥', '글램핑', '숙박'])
add(`${REGION} ${n}`, { category: '코어', relevance: 0.87 });
// T2 — 섬·권역 × 숙소유형
for (const g of [ISLANDS, AREAS]) for (const p of g) for (const s of STAY_CORE.slice(0, 4))
add(`${p} ${s}`, { category: '권역', relevance: g === ISLANDS ? 0.9 : 0.85 });
for (const p of ISLANDS) for (const m of INTENT_CORE)
add(`${p} 펜션 ${m}`, { intent: intentOf(m), category: '권역', relevance: 0.82 });
// T3 — 동반자
for (const w of WITH_CORE) {
add(`${REGION} ${w} 펜션`, { category: '동반자', relevance: 0.91 });
for (const m of INTENT_CORE) add(`${REGION} ${w} 펜션 ${m}`, { intent: intentOf(m), category: '동반자', relevance: 0.8 });
for (const s of STAY_CORE.slice(2, 6)) add(`${REGION} ${w} ${s}`, { category: '동반자', relevance: 0.78 });
}
for (const w of WITH_MORE) {
add(`${REGION} ${w} 펜션`, { category: '동반자', relevance: 0.82 });
add(`${REGION} ${w} 펜션 추천`, { category: '동반자', relevance: 0.75 });
add(`${REGION} ${w} 숙소`, { category: '동반자', relevance: 0.73 });
}
// T4 — 시설·특징
for (const f of FEAT_CORE) {
add(`${REGION} ${f} 펜션`, { category: '시설', relevance: 0.89 });
add(`${REGION} 펜션 ${f}`, { category: '시설', relevance: 0.76 });
for (const m of INTENT_CORE.slice(0, 3)) add(`${REGION} ${f} 펜션 ${m}`, { intent: intentOf(m), category: '시설', relevance: 0.72 });
}
for (const f of FEAT_MORE) {
add(`${REGION} ${f} 펜션`, { category: '시설', relevance: 0.79 });
add(`${REGION} 펜션 ${f}`, { category: '시설', relevance: 0.7 });
}
for (const p of ISLANDS.slice(0, 4)) for (const f of FEAT_CORE)
add(`${p} ${f} 펜션`, { category: '시설', relevance: 0.74 });
// T5 — 시즌·행사
for (const s of SEASON) {
add(`${REGION} ${s} 펜션`, { category: '시즌', relevance: 0.8 });
add(`${REGION} ${s} 펜션 예약`, { intent: 'transactional', category: '시즌', relevance: 0.71 });
add(`${s} ${REGION} 숙소`, { category: '시즌', relevance: 0.68 });
}
for (const o of OCCASION) {
add(`${REGION} ${o} 펜션`, { category: '시즌', relevance: 0.75 });
add(`${REGION} ${o} 펜션 추천`, { category: '시즌', relevance: 0.69 });
}
// T5.5 — 분위기·여행형태
for (const v of VIBE) {
add(`${REGION} ${v} 펜션`, { category: '분위기', relevance: 0.81 });
add(`${REGION} ${v} 숙소`, { category: '분위기', relevance: 0.78 });
add(`${REGION} ${v} 독채`, { category: '분위기', relevance: 0.7 });
}
for (const t of TRAVEL) {
add(`${REGION} ${t} 숙소`, { category: '여행형태', relevance: 0.76 });
add(`${REGION} ${t} 펜션 추천`, { category: '여행형태', relevance: 0.7 });
}
for (const v of VIBE.slice(0, 6)) for (const w of WITH_CORE.slice(0, 3))
add(`${REGION} ${w} ${v} 숙소`, { category: '분위기', relevance: 0.58 });
// T6 — 관광지 앵커
for (const sp of SPOTS) {
add(`${sp} 근처 펜션`, { category: '관광지', relevance: 0.83 });
add(`${sp} 근처 숙소`, { category: '관광지', relevance: 0.8 });
add(`${sp} 펜션 추천`, { category: '관광지', relevance: 0.72 });
add(`${sp} 가까운 숙소`, { category: '관광지', relevance: 0.66 });
}
// T7 — 태그 (칩 UI 용 짧은 패싯)
const TAGS = [...FEAT_CORE, ...FEAT_MORE, ...WITH_CORE, ...STAY_CORE, ...STAY_ALT,
'오션뷰객실', '반려동물동반', '금연객실', '엘리베이터', '와이파이', '취사가능',
'단체가능', '조용한', '신축', '리모델링', '뷰맛집', '인생샷',
...VIBE, ...TRAVEL, '2인전용', '소인원', '뚜벅이', '원도심'];
for (const t of TAGS) add(t, { kind: 'tag', category: '태그', relevance: 0.6 });
// T8 — 질문형 (AEO)
const Q = [];
for (const w of [...WITH_CORE, '아이', '부모님']) Q.push([`${REGION} ${w} 펜션 어디가 좋아요`, 'informational', 0.7]);
for (const f of FEAT_CORE) Q.push([`${REGION}${f} 펜션 있나요`, 'informational', 0.67]);
for (const p of ISLANDS.slice(0, 5)) {
Q.push([`${p} 펜션 어떻게 가나요`, 'informational', 0.64]);
Q.push([`${p} 숙소 예약 언제 해야 하나요`, 'informational', 0.6]);
}
Q.push([`${REGION} 펜션 1박 얼마인가요`, 'transactional', 0.72]);
Q.push([`${REGION} 펜션 바베큐 가능한가요`, 'informational', 0.7]);
Q.push([`${REGION} 펜션 체크인 몇시인가요`, 'informational', 0.66]);
Q.push([`${REGION} 펜션 주차 되나요`, 'informational', 0.66]);
Q.push([`${REGION} 애견동반 펜션 추가요금 있나요`, 'informational', 0.63]);
Q.push([`${REGION} 펜션 성수기 언제인가요`, 'informational', 0.61]);
Q.push([`선유도 들어가는 배 시간표`, 'informational', 0.55]);
Q.push([`${REGION} 여행 몇박이 좋을까요`, 'informational', 0.54]);
for (const [k, i, r] of Q) add(k, { intent: i, category: '질문형', relevance: r });
// T9 — 롱테일: 동반자 × 시설 / 권역 × 동반자 / 시즌 × 동반자
const LONGTAIL = [];
for (const w of WITH_CORE) for (const f of FEAT_CORE) LONGTAIL.push([`${REGION} ${w} ${f} 펜션`, 0.5]);
for (const p of ISLANDS) for (const w of WITH_CORE) LONGTAIL.push([`${p} ${w} 펜션`, 0.48]);
for (const s of SEASON) for (const w of WITH_CORE) LONGTAIL.push([`${REGION} ${s} ${w} 펜션`, 0.44]);
for (const f of FEAT_CORE) for (const f2 of FEAT_MORE) LONGTAIL.push([`${REGION} ${f} ${f2} 펜션`, 0.4]);
for (const a of AREAS) for (const f of FEAT_CORE) LONGTAIL.push([`${a} ${f} 펜션`, 0.42]);
for (const [k, r] of LONGTAIL) {
if (rows.length >= 1000) break;
add(k, { category: '롱테일', relevance: r });
}
const dataset = rows.slice(0, 1000);
mkdirSync('data', { recursive: true });
writeFileSync('data/gunsan-pension-keywords.json', JSON.stringify({
topic: '군산 펜션',
locale: 'ko-KR',
generatedBy: 'hand-authored vocabulary × search-pattern expansion',
count: dataset.length,
items: dataset,
}, null, 2) + '\n');
const by = (f) => dataset.reduce((a, r) => (a[r[f]] = (a[r[f]] ?? 0) + 1, a), {});
console.log(`✅ data/gunsan-pension-keywords.json ${dataset.length}`);
console.log(' 카테고리:', by('category'));
console.log(' 의도 :', by('intent'));
console.log(' 종류 :', by('kind'));