o2o-site-AEO/ontology/scripts/build-nationwide-dataset.mjs
Mina Choi 01098835e9 [chore] docker-compose,ontology: 온톨로지를 이 레포로 들여 compose 한 벌로 띄운다 — 앱 Dockerfile 신설
발행이 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>
2026-09-14 17:41:03 +09:00

161 lines
8.2 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 키워드 데이터셋.
* 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'));