발행이 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>
120 lines
4.6 KiB
TypeScript
120 lines
4.6 KiB
TypeScript
import { asJson, createSql } from './db';
|
|
|
|
const industries = [
|
|
['beauty', 'beauty', '뷰티'],
|
|
['beauty.hair', 'beauty.hair', '미용실'],
|
|
['beauty.nail', 'beauty.nail', '네일샵'],
|
|
['food', 'food', '음식점'],
|
|
['food.korean', 'food.korean', '한식당'],
|
|
['health', 'health', '의료'],
|
|
['health.dental', 'health.dental', '치과'],
|
|
['stay', 'stay', '숙박'],
|
|
['stay.pension', 'stay.pension', '펜션'],
|
|
];
|
|
|
|
const regions = [
|
|
['kr', 'kr', '대한민국'],
|
|
['kr.seoul', 'kr.seoul', '서울'],
|
|
['kr.seoul.gangnam', 'kr.seoul.gangnam', '강남'],
|
|
['kr.seoul.mapo', 'kr.seoul.mapo', '마포'],
|
|
['kr.busan', 'kr.busan', '부산'],
|
|
['kr.busan.haeundae', 'kr.busan.haeundae', '해운대'],
|
|
['kr.jeonbuk', 'kr.jeonbuk', '전북'],
|
|
['kr.jeonbuk.gunsan', 'kr.jeonbuk.gunsan', '군산'],
|
|
];
|
|
|
|
const merchants = [
|
|
{
|
|
externalId: 'site-1001',
|
|
name: '레브살롱',
|
|
industryId: 'beauty.hair',
|
|
regionId: 'kr.seoul.gangnam',
|
|
description: '강남역 3번 출구 앞 프라이빗 헤어살롱. 1:1 디자이너 전담 시스템.',
|
|
siteUrl: 'https://rev-salon.example.com',
|
|
profile: {
|
|
services: ['남자 커트', '여성 펌', '뿌리염색', '두피 클리닉'],
|
|
features: ['주차 가능', '심야 영업', '예약제'],
|
|
priceRange: '30,000~120,000원',
|
|
},
|
|
},
|
|
{
|
|
externalId: 'site-1002',
|
|
name: '헤어랩 강남점',
|
|
industryId: 'beauty.hair',
|
|
regionId: 'kr.seoul.gangnam',
|
|
description: '강남 대형 헤어샵. 염색과 클리닉 전문.',
|
|
siteUrl: 'https://hairlab.example.com',
|
|
profile: {
|
|
services: ['뿌리 염색', '여성 펌', '두피클리닉'],
|
|
features: ['주차가능', '단체 예약'],
|
|
priceRange: '25,000~150,000원',
|
|
},
|
|
},
|
|
{
|
|
externalId: 'site-2001',
|
|
name: '해운대 소담한상',
|
|
industryId: 'food.korean',
|
|
regionId: 'kr.busan.haeundae',
|
|
description: '해운대 해변 인근 한정식집. 제철 해산물 코스 제공.',
|
|
siteUrl: 'https://sodam.example.com',
|
|
profile: {
|
|
services: ['한정식 코스', '점심 특선', '단체 예약'],
|
|
features: ['오션뷰', '룸 완비', '발렛파킹'],
|
|
priceRange: '25,000~80,000원',
|
|
},
|
|
},
|
|
{
|
|
// 실제 업체. 공개 정보로 확인된 항목만 넣는다.
|
|
// 확인됨 : 상호, 군산 원도심(신흥동 말랭이마을 인근), 독채 2개 동, 기준 2인·최대 4인
|
|
// 미확인 : 가격, 바베큐/스파/주차/애견동반 여부 ← 사업자 확인 후 채울 것
|
|
externalId: 'site-3001',
|
|
name: '스테이머뭄',
|
|
industryId: 'stay.pension',
|
|
regionId: 'kr.jeonbuk.gunsan',
|
|
description:
|
|
'군산 원도심 말랭이마을 옆에 자리한 독채 스테이. A동·B동 두 채를 통째로 쓰며 기준 2인, 최대 4인.',
|
|
siteUrl: 'https://www.instagram.com/staymeomoom/',
|
|
profile: {
|
|
services: ['독채 대여', 'A동', 'B동'],
|
|
features: ['독채', '프라이빗', '2인 기준', '최대 4인', '원도심', '감성숙소'],
|
|
audiences: ['커플', '친구', '가족', '혼자'],
|
|
nearby: ['말랭이마을', '신흥동 일본식가옥', '동국사', '초원사진관', '이성당',
|
|
'경암동 철길마을', '근대역사박물관', '월명공원', '시간여행마을'],
|
|
address: '전북특별자치도 군산시 절골길 18 (신흥동)',
|
|
capacity: { standard: 2, max: 4 },
|
|
buildings: 2,
|
|
unverified: ['가격', '바베큐', '스파', '주차', '애견동반'],
|
|
},
|
|
},
|
|
];
|
|
|
|
async function main() {
|
|
const sql = createSql();
|
|
|
|
for (const [id, path, name] of industries) {
|
|
await sql`INSERT INTO industry (id, path, name) VALUES (${id}, ${path}::ltree, ${name})
|
|
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`;
|
|
}
|
|
for (const [id, path, name] of regions) {
|
|
await sql`INSERT INTO region (id, path, name) VALUES (${id}, ${path}::ltree, ${name})
|
|
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name`;
|
|
}
|
|
for (const m of merchants) {
|
|
await sql`
|
|
INSERT INTO merchant (external_id, name, industry_id, region_id, description, profile, site_url)
|
|
VALUES (${m.externalId}, ${m.name}, ${m.industryId}, ${m.regionId},
|
|
${m.description}, ${sql.json(asJson(m.profile))}, ${m.siteUrl})
|
|
ON CONFLICT (external_id) DO UPDATE SET
|
|
name = EXCLUDED.name, description = EXCLUDED.description,
|
|
profile = EXCLUDED.profile, updated_at = now()`;
|
|
}
|
|
|
|
await sql.end();
|
|
console.log(`✅ seed: industry=${industries.length} region=${regions.length} merchant=${merchants.length}`);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error('❌ seed failed:', e);
|
|
process.exit(1);
|
|
});
|