발행이 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>
91 lines
3.6 KiB
TypeScript
91 lines
3.6 KiB
TypeScript
/**
|
|
* 외부 연관키워드·검색량을 데이터셋에 병합한다.
|
|
* npx tsx scripts/import-related.ts data/related-keywords.csv [--apply]
|
|
*
|
|
* 입력은 네이버 검색광고 키워드도구 내려받기 형식(CSV) 또는 같은 필드의 JSON.
|
|
* relKeyword, monthlyPcQcCnt, monthlyMobileQcCnt, compIdx
|
|
*
|
|
* API 클라이언트를 두지 않고 파일 임포트로 한 이유: 검색광고 API 는 계정·HMAC 서명이
|
|
* 필요해 자격증명 없이는 검증할 수 없다. 파일 경로는 지금 바로 동작하고,
|
|
* 나중에 API 를 붙여도 이 임포터를 그대로 재사용한다.
|
|
*/
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize';
|
|
|
|
const DATASET = 'data/gunsan-pension-keywords.json';
|
|
|
|
interface Related { keyword: string; volumePc: number; volumeMobile: number; competition: string | null }
|
|
|
|
function parse(path: string): Related[] {
|
|
const raw = readFileSync(path, 'utf8');
|
|
if (path.endsWith('.json')) {
|
|
return (JSON.parse(raw) as any[]).map(toRelated);
|
|
}
|
|
const lines = raw.split(/\r?\n/).filter((l) => l.trim() && !l.trimStart().startsWith('#'));
|
|
const head = lines.shift()!.split(',').map((h) => h.trim());
|
|
return lines.map((line) => {
|
|
const cells = line.split(',').map((c) => c.trim());
|
|
const o: Record<string, string> = {};
|
|
head.forEach((h, i) => (o[h] = cells[i] ?? ''));
|
|
return toRelated(o);
|
|
});
|
|
}
|
|
|
|
function toRelated(o: any): Related {
|
|
const num = (v: unknown) => {
|
|
const n = Number(String(v ?? '').replace(/[^0-9]/g, ''));
|
|
return Number.isFinite(n) ? n : 0;
|
|
};
|
|
return {
|
|
keyword: String(o.relKeyword ?? o.keyword ?? '').trim(),
|
|
volumePc: num(o.monthlyPcQcCnt),
|
|
volumeMobile: num(o.monthlyMobileQcCnt),
|
|
competition: o.compIdx ? String(o.compIdx).trim() : null,
|
|
};
|
|
}
|
|
|
|
function main() {
|
|
const file = process.argv[2];
|
|
const apply = process.argv.includes('--apply');
|
|
if (!file) { console.error('사용법: tsx scripts/import-related.ts <csv|json> [--apply]'); process.exit(1); }
|
|
|
|
const ds = JSON.parse(readFileSync(DATASET, 'utf8'));
|
|
const existing = new Map<string, any>(ds.items.map((i: any) => [normalizeKeyword(i.keyword), i]));
|
|
|
|
const rows = parse(file).filter((r) => r.keyword);
|
|
let added = 0, enriched = 0, skipped = 0;
|
|
const newItems: any[] = [];
|
|
|
|
for (const r of rows) {
|
|
const canonical = canonicalizeKeyword(r.keyword);
|
|
const norm = normalizeKeyword(canonical);
|
|
if (!norm || isBanned(canonical)) { skipped++; continue; }
|
|
const volume = r.volumePc + r.volumeMobile;
|
|
const hit = existing.get(norm);
|
|
if (hit) {
|
|
hit.volume = volume; hit.competition = r.competition; hit.volumeSource = 'naver-searchad';
|
|
enriched++;
|
|
} else {
|
|
const item = {
|
|
keyword: canonical, intent: 'local', kind: 'keyword', category: '연관',
|
|
relevance: 0.7, volume, competition: r.competition, volumeSource: 'naver-searchad',
|
|
};
|
|
newItems.push(item); existing.set(norm, item); added++;
|
|
}
|
|
}
|
|
|
|
console.log(`입력 ${rows.length}건 → 신규 ${added} · 기존 보강 ${enriched} · 제외 ${skipped}`);
|
|
if (newItems.length) {
|
|
console.log('\n신규 예시');
|
|
for (const i of newItems.slice(0, 8)) console.log(` ${i.keyword} (월 ${i.volume}, 경쟁 ${i.competition ?? '-'})`);
|
|
}
|
|
if (!apply) { console.log('\n파일에 쓰려면 --apply 를 붙일 것.'); return; }
|
|
|
|
ds.items = [...ds.items, ...newItems];
|
|
ds.count = ds.items.length;
|
|
writeFileSync(DATASET, JSON.stringify(ds, null, 2) + '\n');
|
|
console.log(`\n✅ ${DATASET} → ${ds.count}건`);
|
|
}
|
|
|
|
main();
|