발행이 SiteOntology 를 부르는데 서버는 따로 띄워야 했다. 실측(2026-09-14): 서버가 없으면 `[seo] SiteOntology 실패 — 키워드 없이 발행: ConnectError` 로 빌드는 성공하고 메타만 빈다 — 화면으로는 안 보이는 종류다. 한 벌로 묶어 "코드는 올라갔는데 서버가 없는" 상태를 없앤다. - ontology/: gitea.o2o.kr/Web4ai/o2o-site-ontology 를 이 레포로 편입(그 원격은 그대로 남는다) - ontology/Dockerfile(신규): 베이스는 node:22-slim. alpine 은 임베딩 런타임(onnxruntime)이 musl 바이너리를 안 줘서 적재가 ERR_DLOPEN_FAILED 로 죽는다 — 빌드는 성공하고 실행에서만 터진다 - docker-compose.yml: ontology · ontology-postgres(pgvector) · ontology-redis 추가. 자체 DB 를 쓰는 이유는 pgvector 확장 때문이다 — web4ai_db 를 남의 서비스 확장에 묶지 않는다 - 임베딩 모델(120MB)은 이미지에 굽지 않고 볼륨(ontology-model)에 남긴다 - 컨테이너끼리는 `http://ontology:3100` 으로 만난다. `.env` 의 127.0.0.1 은 컨테이너 자기 자신이라 안 닿는다 검증: 3개 기동 · 백엔드 컨테이너에서 ontology:3100/demo HTTP 200 · 마이그레이션·시드 완료 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
161 lines
8.2 KiB
JavaScript
161 lines
8.2 KiB
JavaScript
/**
|
||
* 전국 지역별 펜션 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] ?? []),
|
||
...(r.ski ? ['스키', '스키장 근처', '보드'] : []),
|
||
...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 });
|
||
}
|
||
|
||
// 광역 단위 롤업. ltree 라벨은 ASCII 만 허용하므로 시군 키에서 마지막 마디를 떼어 쓴다.
|
||
const sidoKey = {};
|
||
for (const r of regions) sidoKey[r.sido] ??= r.key.split('.').slice(0, -1).join('.');
|
||
const sidoList = uniq(regions.map((x) => x.sido));
|
||
for (const sido of sidoList) {
|
||
const pseudo = { sido, name: sido, key: sidoKey[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'));
|