/** * 외부 연관키워드·검색량을 데이터셋에 병합한다. * 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 = {}; 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 [--apply]'); process.exit(1); } const ds = JSON.parse(readFileSync(DATASET, 'utf8')); const existing = new Map(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();