② 사전 보강 — 실측으로 찾은 어휘 공백을 메움
- 인원: 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>
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();
|