[feat] solution/backend,site: 발행 사이트 제목·keywords 메타에 SiteOntology 키워드 — 이 가게 자료로 거른 것만

숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를
받고, 이 가게의 확인된 자료로 거른 것만 `<meta name="keywords">` 와 제목 업종어 자리에 싣는다.
실측(2026-09-14, 스테이머뭄 프로필): 추천 10건 중 `군산 독채 마당 펜션`·`군산 독채 복층 펜션`·
`군산 커플 프라이빗 펜션` 이 status=ok 로 왔다 — SiteOntology 사실 필터는 수용 인원과 일부 시설만 본다.
사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다. 메타 태그와 제목은 AI 검색이
그대로 읽는 자리라, 키워드의 모든 낱말이 이 가게 자료에 있을 때만 싣는다.
SiteOntology 쪽 함정도 실측으로 막았다 — 없는 regionId 는 500(외래키), 해석 안 된 query 도 201 로
입력 문자열 검색 결과를 준다.

- services/external/site_ontology.py: publish(generate:false) → match 두 번 호출. 500 이면 지역 없이
  재시도, resolved 가 우리 place_id 가 아니면 실패로 본다
- services/seo_keywords.py: 스냅샷 → 프로필(있음=features · 없음=뺌 · 모름=unverified), 낱말 대조 거르기,
  업종어뿐인 단어 제외, 제목은 유형 레인 코어 중 시·군 이름을 품고 예약·추천이 없는 것. 숙박만
- services/build_service.py: 스냅샷 직후 호출해 snapshot["seo"] 에 싣는다(= 발행 기록). 실패해도 발행 계속
- services/site_payload.py · shared site-payload.ts: 선택 필드 `seo` — 옛 payload·목업은 그대로
- site/src/seo/meta.ts · head.ts: 제목 `<상호> · <대표 키워드>`(15자 미만이면 예전 제목), keywords 태그는
  키워드가 있을 때만(빈 태그를 만들지 않는다)
- config_models.py · .env.example: SITE_ONTOLOGY_URL — 비우면 호출하지 않는다
- docs/ARCHITECTURE.md 발행 파이프라인 · docs/DEVLOG.md

pytest tests/test_seo_keywords.py 16 passed · 백엔드 전체 650 passed(실패 2건은 이전부터:
test_rate_limit_closes_the_tap · test_사이트_디렉터리_밖의_thumbs_에_올린다)
site tsc·eslint 통과 · vitest 63 passed · frontend·admin tsc 통과
실제 발행 한 바퀴(로컬 SiteOntology :3100): 스테이머뭄 → `<title>스테이머뭄 · 군산 독채펜션</title>` +
keywords 9건, 렌더 게이트 불일치 0건. SiteOntology 없는 payload 는 제목·head 가 예전 그대로

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBR4o9Nth3g4eoQEnL1iat
This commit is contained in:
민헌 2026-09-14 14:38:24 +09:00
parent c07e2bddf4
commit b2c8bb033e
13 changed files with 882 additions and 2 deletions

View File

@ -42,6 +42,11 @@ TOUR_API_KEY=
# 그래도 API 가 필수로 요구하는 필드라 값을 채워 보낸다.
SUNO_API_KEY=
SUNO_CALLBACK_URL=https://example.com/api/suno/callback
# 발행 사이트 메타 키워드(keywords · 제목)를 받아 올 SiteOntology 주소(o2o-site-ontology, 기본 :3100).
# 비우면 그 단계만 건너뛴다 — 제목·메타가 예전 그대로 나간다.
# ★ 워커가 부르는 주소다. compose 로 띄우면 컨테이너 안에서 보는 주소(http://host.docker.internal:3100),
# 백엔드를 네이티브로 돌리면 http://127.0.0.1:3100
SITE_ONTOLOGY_URL=
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).

View File

@ -37,7 +37,10 @@ React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포
```
BUILD 잡 (worker) ─ services/build_service.py:99 run_build()
├ build_snapshot → site_versions 행 insert (원본 데이터, JSONB)
├ build_snapshot → 원본 데이터(JSONB)
├ seo_keywords.fetch() → SiteOntology 추천을 이 가게 자료로 거른 키워드 → snapshot["seo"]
│ (숙박만 · 설정 없거나 실패하면 생략 · 발행은 계속)
│ → site_versions 행 insert
├ 1차 게이트 (DB 사실 기준) → publish_gate.evaluate()
├ site_payload.emit_payload() → out/payloads/<slug>.json ★ 백엔드의 유일한 산출물

View File

@ -5,6 +5,37 @@
---
## 2026-09-14 — 발행 사이트 제목·keywords 메타에 SiteOntology 키워드를 싣는다
**무슨 일** — 숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를
받고, 거른 결과를 `<meta name="keywords">` 와 제목 업종어 자리에 싣는다.
```
스냅샷 → 프로필(확인된 fact · 주소 · 발행되는 주변 관광지)
→ POST /v1/merchants/publish (generate:false) → POST /v1/match (query=place_id)
→ 거르기 → snapshot["seo"] → payload.seo
<title>스테이,머뭄 · 군산 독채펜션</title> · <meta name="keywords" content="군산 펜션 독채, …">
```
**★ 거르기가 필요한 이유 (실측)** — 스테이머뭄 프로필로 받은 추천 10건 중 `군산 독채 마당 펜션`·
`군산 독채 복층 펜션`·`군산 커플 프라이빗 펜션` 이 status=ok 로 왔다. SiteOntology 의 사실 필터는 수용 인원과
일부 시설만 보기 때문이다. 사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다.
**키워드의 모든 낱말이 이 가게 자료에 있어야** 싣는다. 이 규칙 하나로 셋이 같이 걸리고, 10건이 4건이 됐다.
제목에는 `예약`·`추천` 이 붙은 것과 시·군 이름이 없는 것도 뺀다. 규칙의 단일 출처는 `services/seo_keywords.py`.
**★ SiteOntology 쪽 함정 (실측)**
- region 표에 없는 `regionId` 를 보내면 **500**(외래키 위반). 표 내용은 적재한 데이터셋에 따라 달라 우리가 모른다
→ 500 이면 지역 없이 한 번 더 보낸다.
- 해석되지 않은 `query` 에도 **201** 로 입력 문자열 검색 결과를 준다(`나운동 숙소` …) → `resolved`
우리 place_id 가 아니면 버린다.
**경계** — SiteOntology 는 **수정하지 않았다**. 설정(`SITE_ONTOLOGY_URL`)이 비면 호출하지 않고, 실패하면
키워드 없이 예전 제목으로 발행한다. 키워드는 스냅샷에 실려 `site_versions.snapshot` 이 곧 발행 기록이다.
**남은 것** — SiteOntology 매칭은 지역으로 거르지 않는다. 지금은 낱말 대조가 다른 지역 단어를 막지만,
운영에 전국 데이터가 들어가면 SiteOntology 에 지역 필터를 넣는 것이 맞다.
---
## 2026-09-11 — 발행하면 이 숙소의 노래가 한 곡 생긴다 (가사 Gemini → 작곡 Suno)
**무슨 일** — `/s/stay` 시안에는 헤더에 노래 플레이어가 있는데, 그건 손으로 채운 목업이라

View File

@ -136,6 +136,9 @@ class ExternalApiConfig(BaseSettings):
# ★ 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다.
# 그래도 API 가 필수로 요구하는 필드라 값을 들고 있는다(services/external/suno.py 주석).
suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL")
# 발행 사이트 메타 키워드(keywords · 제목)를 받아 오는 사내 서비스(o2o-site-ontology). 비면 그 단계만
# 건너뛴다 — 제목·메타가 예전 그대로 나간다(services/seo_keywords).
site_ontology_url: str = Field("", validation_alias="SITE_ONTOLOGY_URL")
# .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다.

View File

@ -29,7 +29,16 @@ from common.logger import LOG
from common.utils.gtime import GTime
from crud.site_crud import SiteCRUD
from crud.place_crud import PlaceCRUD
from services import azure_static, indexnow, publish_gate, render_report, site_payload, site_thumbnail, song_service
from services import (
azure_static,
indexnow,
publish_gate,
render_report,
seo_keywords,
site_payload,
site_thumbnail,
song_service,
)
from services.local_content_service import LocalContentService
from services.site_payload import emit_payload
from services.snapshot import build_snapshot
@ -157,6 +166,19 @@ async def run_build(job: dict) -> dict:
snapshot = await build_snapshot(place)
# ★ 메타 태그용 검색 키워드(SiteOntology). **스냅샷에 싣는다** — payload 는 스냅샷만 보고 만들고,
# "이 버전에 어떤 키워드가 나갔나" 가 site_versions.snapshot 에 남는다(services/seo_keywords 머리주석).
# ★ 실패해도 빌드는 계속한다. 주변 정보·노래와 같은 규칙이다 — 키워드 없이 예전 제목·메타로 나간다.
# ★ 재빌드(publish=False)에도 부른다. 로컬 임베딩이라 비용이 없고, 재빌드한 버전과 발행한 버전의
# 제목이 갈리면 "눌러 본 것과 나간 것이 다르다" 가 된다.
seo: dict | None = None
try:
seo = await seo_keywords.fetch(place_id, snapshot)
except Exception as ex: # noqa: BLE001 — 키워드 실패가 발행을 죽이면 안 된다
LOG.w(f"[build] place={place_id} 검색 키워드 실패(키워드 없이 발행): {type(ex).__name__}: {ex}")
if seo:
snapshot["seo"] = seo
v_err, version_no = await DB_SESSION_MNG.execute_lambda(
site_versions.DBType(), DBWRType.DB_READ.value, lambda s: _site_crud.next_version_no(s, site.site_id)
)
@ -180,6 +202,9 @@ async def run_build(job: dict) -> dict:
# 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다.
if song_result is not None:
result["song"] = song_result
# "제목이 왜 예전 그대로인가" 도 같다 — 키워드가 실렸으면 잡 결과에 보인다(없으면 로그의 [seo] 줄).
if seo is not None:
result["seo"] = seo
now = GTime.UTC()
async def _fail(reason: str, gate: publish_gate.GateResult | None = None, extra: dict | None = None):

View File

@ -0,0 +1,87 @@
"""SiteOntology(o2o-site-ontology) 클라이언트 — 이 가게에 맞는 검색 키워드를 받아 온다.
SiteOntology 펜션 SEO/AEO 키워드 사전(pgvector) 들고, 업체 프로필에 맞는 단어를 골라 주는
사내 서비스다. 여기서는 창구 개만 쓴다(실측 2026-09-14, 로컬 :3100).
POST /v1/merchants/publish {externalId, name, industryId, regionId, description, profile, generate:false}
201 {merchant, generation: "skipped"} 업체를 저장만 한다. 키워드는 주지 않는다
POST /v1/match {query: <externalId>, limit}
201 {resolved, lanes, matches[], byLane[], excluded[]} 저장된 프로필로 추천한다
부르는 이유: "프로필을 주면 키워드를 돌려주는" 창구가 번에는 없다. match query 문자열만
받고, externalId 넣으면 저장된 업체로 해석해 프로필로 추천한다. 그래서 먼저 publish 프로필을
이번 빌드의 스냅샷 값으로 맞춘다.
generate:false 반드시 보낸다. 빠뜨리면 SiteOntology LLM 키워드 생성을 큐에 넣는다
결과는 우리가 쓰지 않는 창구(/seo)로만 나가고, 로컬은 mock LLM 이라 가짜 단어가 사전에 쌓인다.
regionId SiteOntology region 표에 없으면 **500** 이다(외래키 위반, 실측). 표는 적재한 데이터셋에
따라 달라서(군산만 / 전국 54) 우리가 없다 500 이면 regionId 비워 보낸다.
지역이 비면 유형 추천에서 "군산" 빠져 품질이 떨어지지만(실측: 1위가 `독채펜션`), 없는 것보다 낫고
지역 없는 단어는 호출측 거르기(seo_keywords) 제목에서 뺀다.
externalId 해석되지 않으면 match 에러가 아니라 **입력 문자열 자체로 검색한 결과** 201 준다
(실측: "no-such-place-id" `나운동 숙소`·`선유도 숙소 예약 언제 해야 하나요`). 그걸 쓰면 남의 동네 단어가
나간다 resolved 우리 externalId 아니면 실패로 본다.
실패는 발행을 막지 않는다. 여기서는 SiteOntologyError 올리고, 호출측이 잡아 키워드 없이 굽는다.
"""
import httpx
from common.logger import LOG
from config.server_configs import external_api_config
# 로컬 임베딩 모델이라 호출당 1~2초다. 넉넉히 잡되 발행 잡 데드라인(900s)을 잡아먹지 않게 끊는다.
TIMEOUT_SEC = 15.0
# 거르기(seo_keywords)에서 절반 넘게 떨어진다(실측: 10건 → 4건). 메타 태그 10개를 채우려면 넉넉히 받는다.
DEFAULT_LIMIT = 40
PUBLISH_PATH = "/v1/merchants/publish"
MATCH_PATH = "/v1/match"
class SiteOntologyError(RuntimeError):
"""SiteOntology 호출 실패(네트워크·타임아웃·4xx/5xx·업체 미해석). 발행은 계속된다."""
def base_url() -> str:
return (external_api_config.site_ontology_url or "").strip().rstrip("/")
def is_configured() -> bool:
return bool(base_url())
async def _post(client: httpx.AsyncClient, path: str, body: dict) -> dict:
res = await client.post(f"{base_url()}{path}", json=body)
if res.status_code >= 400:
raise SiteOntologyError(f"{path} HTTP {res.status_code}: {res.text[:200]}")
try:
data = res.json()
except ValueError as ex:
raise SiteOntologyError(f"{path} 응답이 JSON 이 아니다: {res.text[:200]}") from ex
if not isinstance(data, dict):
raise SiteOntologyError(f"{path} 응답 모양이 다르다: {type(data).__name__}")
return data
async def match_for_merchant(merchant: dict, limit: int = DEFAULT_LIMIT) -> dict:
"""업체를 저장하고, 그 업체로 해석된 추천 결과(/v1/match 응답)를 돌려준다.
merchant publish 요청 본문이다(generate 여기서 붙인다). 실패하면 SiteOntologyError."""
body = {**merchant, "generate": False}
try:
async with httpx.AsyncClient(timeout=TIMEOUT_SEC) as client:
try:
await _post(client, PUBLISH_PATH, body)
except SiteOntologyError as ex:
if not body.get("regionId"):
raise
LOG.w(f"[site-ontology] regionId={body['regionId']} 로 저장하지 못했다 — 지역 없이 다시 보낸다: {ex}")
body = {**body, "regionId": None}
await _post(client, PUBLISH_PATH, body)
result = await _post(client, MATCH_PATH, {"query": merchant["externalId"], "limit": limit})
except httpx.HTTPError as ex:
raise SiteOntologyError(f"{type(ex).__name__}: {ex}") from ex
resolved = result.get("resolved") or {}
if resolved.get("externalId") != merchant["externalId"]:
raise SiteOntologyError(f"업체가 해석되지 않았다 — externalId={merchant['externalId']}")
return result

View File

@ -0,0 +1,294 @@
"""발행 사이트 메타 태그용 검색 키워드 — SiteOntology 추천을 이 가게의 확인된 자료로 한 번 더 거른다.
스냅샷 업체 프로필(build_merchant) SiteOntology publish + match 거르기(select)
snapshot["seo"] payload.seo <meta name="keywords"> · <title> 업종어 자리
스냅샷에 싣는다(site_versions.snapshot). payload 스냅샷만 보고 만든다는 원칙(site_payload 머리주석)
그대로 지키고, "이 버전에 어떤 키워드가 나갔나" 발행 기록으로 남는다. SiteOntology 쪽에는 남기지 않는다.
숙박만 부른다. SiteOntology 사전은 펜션 키워드뿐이다(2026-09-14 기준 industry=stay.pension).
다른 업종으로 부르면 펜션 단어가 카페 사이트의 메타 태그에 붙는다.
거르기 규칙은 하나다 **키워드의 모든 낱말이 가게의 확인된 자료에 있어야 한다.**
SiteOntology 사실 필터는 수용 인원과 일부 시설(바베큐·수영장·스파) 본다. 실측(2026-09-14, 스테이머뭄
프로필)에서 `군산 독채 마당 펜션`·`군산 독채 복층 펜션` status=ok 왔다 마당·복층은 확인된 적이 없다.
사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장) 있다. 낱말 대조 하나로 셋이 함께 걸린다.
메타 태그와 제목은 AI 검색이 그대로 읽는 자리라, 확인 시설을 광고하는 단어는 지어낸 문장과 같다
(solution/site/src/seo/meta.ts 머리주석 description 지어내지 않는다).
"자료" 상호·소개문·주소·확인된 시설/객실 fact·발행되는 주변 관광지다. 미확인(unverified) 자료가 아니다.
"""
import re
import unicodedata
from common.enums import LocalContentType, PlaceCategory
from common.logger import LOG
from services.external import site_ontology
from services.site_payload import _parse_address_parts
LODGING_INDUSTRY = "stay.pension"
MAX_KEYWORDS = 10
MAX_NEARBY = 8
# ★ SiteOntology 의 시설 어휘(match.rules.ts AMENITY_SYNONYMS)로 옮긴다 — 저쪽이 이 낱말로 보유 시설을 판정한다.
# 값이 "true" 면 있음 → features, "false" 면 **없음** → 어디에도 넣지 않는다(그래야 저쪽이 그 시설 키워드를 배제한다),
# fact 가 아예 없으면 **모름** → unverified(저쪽이 배제하지 않고 보류한다 — 없음과 모름은 다르다).
_AMENITY_FACTS = {
"bbq_available": "바베큐",
"parking": "주차",
"pet_allowed": "애견동반",
"breakfast": "조식",
}
# 사장님이 확인한 글자 그대로 싣는 fact(객실 단위 포함). "독채"·"오션뷰" 같은 유형·전망어가 여기서 온다.
_FEATURE_TEXT_KEYS = ("room_type", "building_scale", "facilities", "view")
_CAPACITY_KEYS = ("max_capacity", "accommodation_capacity")
# (ISO 3166-2 시·도, 시·군) → SiteOntology region 키(data/regions.json 54개).
# ★ 시·도를 함께 본다 — 고성군은 강원과 경남에 둘 다 있다.
# ★ 을왕리(kr.incheon.yeongjong)는 넣지 않는다. 행정구역으로는 인천 중구인데 중구 전체를 을왕리로 보낼 수 없다.
_REGION_KEYS = {
("KR-41", "가평군"): "kr.gyeonggi.gapyeong", ("KR-41", "양평군"): "kr.gyeonggi.yangpyeong",
("KR-41", "포천시"): "kr.gyeonggi.pocheon", ("KR-41", "파주시"): "kr.gyeonggi.paju",
("KR-28", "강화군"): "kr.incheon.ganghwa",
("KR-42", "춘천시"): "kr.gangwon.chuncheon", ("KR-42", "홍천군"): "kr.gangwon.hongcheon",
("KR-42", "인제군"): "kr.gangwon.inje", ("KR-42", "평창군"): "kr.gangwon.pyeongchang",
("KR-42", "정선군"): "kr.gangwon.jeongseon", ("KR-42", "강릉시"): "kr.gangwon.gangneung",
("KR-42", "속초시"): "kr.gangwon.sokcho", ("KR-42", "양양군"): "kr.gangwon.yangyang",
("KR-42", "고성군"): "kr.gangwon.goseong", ("KR-42", "동해시"): "kr.gangwon.donghae",
("KR-42", "삼척시"): "kr.gangwon.samcheok", ("KR-42", "태백시"): "kr.gangwon.taebaek",
("KR-43", "단양군"): "kr.chungbuk.danyang", ("KR-43", "제천시"): "kr.chungbuk.jecheon",
("KR-43", "충주시"): "kr.chungbuk.chungju", ("KR-43", "괴산군"): "kr.chungbuk.goesan",
("KR-44", "태안군"): "kr.chungnam.taean", ("KR-44", "보령시"): "kr.chungnam.boryeong",
("KR-44", "서산시"): "kr.chungnam.seosan", ("KR-44", "공주시"): "kr.chungnam.gongju",
("KR-44", "부여군"): "kr.chungnam.buyeo",
("KR-45", "군산시"): "kr.jeonbuk.gunsan", ("KR-45", "부안군"): "kr.jeonbuk.buan",
("KR-45", "전주시"): "kr.jeonbuk.jeonju", ("KR-45", "무주군"): "kr.jeonbuk.muju",
("KR-45", "남원시"): "kr.jeonbuk.namwon",
("KR-46", "여수시"): "kr.jeonnam.yeosu", ("KR-46", "순천시"): "kr.jeonnam.suncheon",
("KR-46", "담양군"): "kr.jeonnam.damyang", ("KR-46", "구례군"): "kr.jeonnam.gurye",
("KR-46", "해남군"): "kr.jeonnam.haenam", ("KR-46", "완도군"): "kr.jeonnam.wando",
("KR-46", "보성군"): "kr.jeonnam.boseong",
("KR-47", "경주시"): "kr.gyeongbuk.gyeongju", ("KR-47", "포항시"): "kr.gyeongbuk.pohang",
("KR-47", "안동시"): "kr.gyeongbuk.andong", ("KR-47", "영덕군"): "kr.gyeongbuk.yeongdeok",
("KR-47", "울진군"): "kr.gyeongbuk.uljin", ("KR-47", "문경시"): "kr.gyeongbuk.mungyeong",
("KR-48", "거제시"): "kr.gyeongnam.geoje", ("KR-48", "통영시"): "kr.gyeongnam.tongyeong",
("KR-48", "남해군"): "kr.gyeongnam.namhae", ("KR-48", "하동군"): "kr.gyeongnam.hadong",
("KR-48", "사천시"): "kr.gyeongnam.sacheon", ("KR-48", "산청군"): "kr.gyeongnam.sancheong",
("KR-26", "기장군"): "kr.busan.gijang",
("KR-49", "제주시"): "kr.jeju.jejusi", ("KR-49", "서귀포시"): "kr.jeju.seogwipo",
}
# 자료에 없어도 되는 낱말 — 업종어와 "근처" 류. 무엇을 주장하지 않는다.
_GENERIC_WORDS = frozenset({"펜션", "숙소", "숙박", "스테이", "근처", "가까운", "주변", "인근", "예약", "추천"})
# "독채펜션"·"감성숙소" 처럼 붙여 쓴 업종어는 떼고 앞부분만 자료와 대조한다.
_GENERIC_SUFFIXES = ("펜션", "숙소", "스테이")
# 제목에는 싣지 않는 낱말. `스테이,머뭄 · 군산 독채펜션 예약` 은 검색어로는 맞아도 가게 이름 옆에서는 광고 문구다.
_TITLE_BLOCKED_WORDS = frozenset({"예약", "추천"})
# 한 글자 낱말("봄"·"뷰")은 소개문 어딘가에 우연히 들어 있어 대조가 무의미하다 — 통과시키지 않는다.
_MIN_CORE_LEN = 2
_NON_WORD = re.compile(r"[^\w]")
def _text(value) -> str:
return str(value).strip() if value is not None else ""
def _compact(text: str) -> str:
"""대조용 표기 — 공백·구두점을 지우고 소문자로. "스테이,머뭄""스테이 머뭄" 이 같아진다."""
return _NON_WORD.sub("", unicodedata.normalize("NFKC", text or "").lower())
def _number(value) -> int | None:
try:
return int(float(value))
except (TypeError, ValueError):
return None
def region_key(*addresses: str | None) -> str | None:
"""주소 → SiteOntology region 키. 표에 없으면 None(지어내지 않는다)."""
parts = _parse_address_parts(*addresses)
locality = (parts.get("addressLocality") or "").split()
if not locality:
return None
return _REGION_KEYS.get((parts["addressRegionCode"], locality[0]))
def _locality_word(*addresses: str | None) -> str:
"""사람이 검색창에 치는 시·군 이름 — "군산시""군산". 제목 키워드가 이 낱말을 품어야 한다."""
locality = (_parse_address_parts(*addresses).get("addressLocality") or "").split()
if not locality:
return ""
word = locality[0]
return word[:-1] if len(word) > 2 and word.endswith(("", "", "")) else word
def build_merchant(place_id: str, snapshot: dict) -> dict | None:
"""스냅샷 → SiteOntology publish 요청 본문. 숙박이 아니거나 상호가 없으면 None.
스냅샷만 읽는다 스냅샷에는 노출 가능한 (VERIFIED/CORRECTED fact · 발행 안의 지역 정보) 있다.
미검증 fact 프로필에 실으면 그걸 근거로 고른 키워드가 메타 태그로 나간다."""
place = (snapshot or {}).get("place") or {}
if _number(place.get("category")) != PlaceCategory.LODGING.value:
return None
name = _text(place.get("name"))
if not name:
return None
values: dict[str, list[str]] = {}
for fact in (snapshot or {}).get("facts") or []:
if not isinstance(fact, dict):
continue
value = _text(fact.get("value"))
if value:
values.setdefault(_text(fact.get("key")), []).append(value)
features: list[str] = []
unverified: list[str] = []
for key, label in _AMENITY_FACTS.items():
found = values.get(key)
if not found:
unverified.append(label)
elif "true" in found:
features.append(label)
for key in _FEATURE_TEXT_KEYS:
for value in values.get(key, []):
if value not in features:
features.append(value)
capacities = [n for key in _CAPACITY_KEYS for n in map(_number, values.get(key, [])) if n]
intro = next(iter(values.get("intro", [])), "")
description = next((p.strip() for p in intro.split("\n") if p.strip()), "")
nearby: list[str] = []
for row in ((snapshot or {}).get("local") or {}).get("contents") or []:
if not isinstance(row, dict) or row.get("content_type") != LocalContentType.ATTRACTION.value:
continue
attraction = _text((row.get("body") or {}).get("name")) or _text(row.get("title"))
if attraction and attraction not in nearby:
nearby.append(attraction)
if len(nearby) >= MAX_NEARBY:
break
road_address, address = place.get("road_address"), place.get("address")
profile = {
"address": _text(road_address) or _text(address),
"capacity": {"max": max(capacities)} if capacities else None,
"features": features,
"nearby": nearby,
"unverified": unverified,
}
return {
"externalId": place_id,
"name": name,
"industryId": LODGING_INDUSTRY,
"regionId": region_key(road_address, address),
"description": description,
"profile": {key: value for key, value in profile.items() if value},
}
def _evidence(merchant: dict) -> str:
"""이 가게의 확인된 자료를 한 덩어리로. 키워드 낱말은 여기에 들어 있어야 한다."""
profile = merchant.get("profile") or {}
parts = [
merchant.get("name"), merchant.get("description"), profile.get("address"),
*(profile.get("features") or []), *(profile.get("nearby") or []),
]
return _compact(" ".join(_text(p) for p in parts if p))
def _supported(keyword: str, evidence: str) -> bool:
"""모든 낱말이 자료에 있고, **자료로 확인한 낱말이 하나는 있어야** 한다.
조건이 없으면 `숙소` 처럼 업종어뿐인 단어가 "주장하는 게 없다" 이유로 통과한다
실측(2026-09-14, 실제 발행 바퀴)에서 메타 10 칸이 `숙소` 였다. 어느 가게에나 붙는 단어라
가게를 설명하지 못한다."""
specific = False
for word in keyword.split():
if word in _GENERIC_WORDS:
continue
core = next((word[: -len(s)] for s in _GENERIC_SUFFIXES if word.endswith(s) and len(word) > len(s)), word)
core = _compact(core)
if len(core) < _MIN_CORE_LEN or core not in evidence:
return False
specific = True
return specific
def _is_question(item: dict) -> bool:
canonical = _text(item.get("canonical"))
return item.get("category") == "질문형" or "?" in canonical or canonical.endswith(("", ""))
def _usable(item, evidence: str) -> str | None:
"""메타 태그에 실어도 되는 추천이면 그 표기를, 아니면 None."""
if not isinstance(item, dict) or item.get("status") != "ok" or _is_question(item):
return None
canonical = _text(item.get("canonical"))
return canonical if canonical and _supported(canonical, evidence) else None
def _title_keyword(result: dict, evidence: str, locality: str) -> str | None:
"""제목 업종어 자리에 넣을 대표 키워드 — 유형 레인에서 고른다.
SiteOntology 설계상 "한 페이지의 주력 키워드는 1개, 유형 레인 1위가 메인 페이지 주력" 이다.
다만 1위가 `군산 펜션 독채`(시설) 이고 2위가 `군산 독채펜션`(코어) 식으로 오므로 코어를 앞에 둔다.
· 이름을 품어야 한다. `독채펜션` 남으면 지금 제목(` 군산시 숙소`)보다 지역 신호가 약해진다."""
lane = next(
(l for l in result.get("byLane") or [] if isinstance(l, dict) and l.get("key") == "type"),
{},
)
items = [i for i in lane.get("items") or [] if isinstance(i, dict)]
for item in sorted(items, key=lambda i: i.get("category") != "코어"):
canonical = _usable(item, evidence)
if not canonical or _TITLE_BLOCKED_WORDS.intersection(canonical.split()):
continue
if locality and locality not in canonical:
continue
return canonical
return None
def select(result: dict, merchant: dict) -> dict:
"""/v1/match 응답 → {keywords, titleKeyword?}. 순위는 SiteOntology 의 융합 순위를 그대로 따른다."""
evidence = _evidence(merchant)
keywords: list[str] = []
seen: set[str] = set()
for item in result.get("matches") or []:
canonical = _usable(item, evidence)
if not canonical or _compact(canonical) in seen:
continue
seen.add(_compact(canonical))
keywords.append(canonical)
if len(keywords) >= MAX_KEYWORDS:
break
seo: dict = {"keywords": keywords}
title = _title_keyword(result, evidence, _locality_word((merchant.get("profile") or {}).get("address")))
if title:
seo["titleKeyword"] = title
return seo
async def fetch(place_id: str, snapshot: dict) -> dict | None:
"""스냅샷에 실을 seo 값. 못 만들면 None — 예외를 올리지 않는다(키워드는 발행을 막지 않는다)."""
if not site_ontology.is_configured():
return None
merchant = build_merchant(place_id, snapshot)
if merchant is None:
return None
try:
result = await site_ontology.match_for_merchant(merchant)
except site_ontology.SiteOntologyError as ex:
LOG.w(f"[seo] place={place_id} SiteOntology 실패 — 키워드 없이 발행: {ex}")
return None
seo = select(result, merchant)
LOG.i(
f"[seo] place={place_id} 추천 {len(result.get('matches') or [])}건 → 메타 {len(seo['keywords'])}"
f" · 제목 {seo.get('titleKeyword') or '(기존 제목)'}"
)
if not seo["keywords"] and "titleKeyword" not in seo:
return None
return seo

View File

@ -975,9 +975,26 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict:
],
"narrative": narrative,
"theme": theme,
# ★ 검색 키워드(SiteOntology). 스냅샷에 있을 때만 싣는다 — 옛 스냅샷·SiteOntology 가 꺼진 빌드에는 없고,
# 그때 렌더러는 제목·메타를 예전 그대로 굽는다(solution/site/src/seo/meta.ts).
**_seo_entry(snapshot.get("seo")),
}
def _seo_entry(value) -> dict:
"""스냅샷의 seo(services/seo_keywords) → payload 의 `seo`. 없으면 키 자체를 만들지 않는다.
여기서 다시 거르지 않는다 가게 자료로 거르는 곳은 seo_keywords 곳이다. 모양만 확인한다.
seo 만들지 않는다. 배열은 '받았는데 비었다' 읽힌다(itineraries 같은 규칙)."""
if not isinstance(value, dict):
return {}
keywords = [_text(k) for k in value.get("keywords") or [] if _text(k)]
title = _text(value.get("titleKeyword"))
if not keywords and not title:
return {}
return {"seo": {"keywords": keywords, **({"titleKeyword": title} if title else {})}}
def _as_float(value):
try:
return float(value) if value not in (None, "") else None

View File

@ -0,0 +1,321 @@
"""발행 사이트 메타 태그 키워드(SiteOntology).
경로가 절대 하면 되는 :
- 가게 자료에 없는 낱말이 키워드를 메타 태그·제목에 싣는
(확인 시설 · 다른 권역 · 가격 주장)
- SiteOntology 실패로 발행을 막는 키워드는 곁들이다
- 해석되지 않은 업체의 추천(입력 문자열로 검색한 결과) 쓰는
- 숙박이 아닌 업종에 펜션 키워드를 붙이는
MATCH matches 1~10 · byLane 실제 응답에서 옮겼다 2026-09-14 로컬 SiteOntology(:3100)
스테이머뭄 프로필(features 독채·프라이빗, audiences 커플·가족, nearby 말랭이마을·동국사) 보낸 결과.
11~13 같은 사전에 실제로 있는 단어로, 거르기 규칙을 보려고 붙였다.
"""
import json
import uuid
import httpx
import pytest
from sqlalchemy import text
from common.enums import JobStatus, LocalContentType, MediaStatus, PlaceCategory, SiteStatus, SourceType
from crud.job_crud import JobQueue
from services import seo_keywords
from services.external import site_ontology
from worker.handlers import build_handler
from worker.runner import Worker
def _kw(canonical, category, intent="local", status="ok"):
return {"canonical": canonical, "category": category, "intent": intent, "kind": "keyword", "status": status}
MATCH = {
"mode": "fusion",
"matches": [
_kw("군산 펜션 독채", "시설"),
_kw("군산 독채펜션", "코어"),
_kw("군산 독채 마당 펜션", "롱테일"),
_kw("군산 독채 복층 펜션", "롱테일"),
_kw("군산 커플 프라이빗 펜션", "롱테일"),
_kw("군산 독채펜션 예약", "코어", intent="transactional"),
_kw("군산에 독채 펜션 있나요", "질문형", intent="informational"),
_kw("군산 가족 독채펜션", "동반자"),
_kw("말랭이마을 근처 숙소", "관광지"),
_kw("군산 프라이빗한 독채", "분위기"),
_kw("선유도 독채펜션", "권역"),
_kw("군산 펜션 최저가", "의도", intent="transactional"),
_kw("군산 주차 펜션", "시설", status="hold"),
],
"byLane": [
{"key": "type", "label": "유형", "items": [
_kw("군산 펜션 독채", "시설"),
_kw("군산 독채펜션", "코어"),
_kw("군산 독채 마당 펜션", "롱테일"),
]},
{"key": "place", "label": "위치", "items": [_kw("말랭이마을 근처 숙소", "관광지")]},
],
}
def _fact(key, value, unit_id=None):
return {"key": key, "value": value, "scope": "unit" if unit_id else "place", "unit_id": unit_id, "status": 3}
def _snapshot(**place):
return {
"place": {
"name": "스테이,머뭄", "category": PlaceCategory.LODGING.value,
"road_address": "전북특별자치도 군산시 절골길 18", "address": None, **place,
},
"facts": [
_fact("intro", "군산 원도심 말랭이마을 옆 독채 스테이.\n두 번째 문단은 싣지 않는다."),
_fact("bbq_available", "false"),
_fact("parking", "true"),
_fact("room_type", "독채", unit_id="u1"),
_fact("max_capacity", "4", unit_id="u1"),
_fact("max_capacity", "2", unit_id="u2"),
],
"local": {"contents": [
{"content_type": LocalContentType.ATTRACTION.value, "title": "말랭이마을", "body": {"name": "말랭이마을"}},
{"content_type": LocalContentType.RESTAURANT.value, "title": "이성당", "body": {"name": "이성당"}},
{"content_type": LocalContentType.ATTRACTION.value, "title": "동국사", "body": {}},
]},
}
# ── 요청 만들기 ─────────────────────────────────────────────────────────────
def test_스냅샷으로_업체_프로필을_만든다():
"""검증: 확인된 fact·지역 정보가 든 숙박 스냅샷.
기대결과: 있음(true) features, 모름(fact 없음) unverified, 없음(false) 어디에도 없다."""
merchant = seo_keywords.build_merchant("place-1", _snapshot())
assert merchant == {
"externalId": "place-1",
"name": "스테이,머뭄",
"industryId": "stay.pension",
"regionId": "kr.jeonbuk.gunsan",
"description": "군산 원도심 말랭이마을 옆 독채 스테이.",
"profile": {
"address": "전북특별자치도 군산시 절골길 18",
"capacity": {"max": 4},
"features": ["주차", "독채"],
# 음식점(이성당)은 주변 관광지가 아니다.
"nearby": ["말랭이마을", "동국사"],
# ★ 바베큐는 false(없음)라 여기 없다 — 넣으면 SiteOntology 가 배제 대신 보류한다.
"unverified": ["애견동반", "조식"],
},
}
def test_숙박이_아니면_부르지_않는다():
assert seo_keywords.build_merchant("place-1", _snapshot(category=PlaceCategory.CAFE.value)) is None
def test_표에_없는_지역은_지역을_비운다():
"""검증: SiteOntology 지역 표(54개)에 없는 시·군.
기대결과: 지어내지 않고 None 없는 키를 보내면 SiteOntology 500 이다."""
merchant = seo_keywords.build_merchant("place-1", _snapshot(road_address="서울특별시 강남구 테헤란로 1"))
assert merchant["regionId"] is None
def test_같은_이름의_시군은_시도로_가른다():
assert seo_keywords.region_key("강원특별자치도 고성군 토성면 1") == "kr.gangwon.goseong"
assert seo_keywords.region_key("경상남도 고성군 고성읍 1") is None
# ── 거르기 ─────────────────────────────────────────────────────────────────
def test_자료가_뒷받침하는_키워드만_남긴다():
"""검증: 실제 추천 결과를 이 가게 자료(주차·독채·원도심·말랭이마을·동국사)로 거른다.
기대결과: 마당·복층·커플·가족·프라이빗(확인 ), 선유도(다른 권역), 최저가(가격 주장),
질문형, 보류(hold) 모두 빠진다. 순위는 SiteOntology 순서 그대로."""
merchant = seo_keywords.build_merchant("place-1", _snapshot())
seo = seo_keywords.select(MATCH, merchant)
assert seo["keywords"] == ["군산 펜션 독채", "군산 독채펜션", "군산 독채펜션 예약", "말랭이마을 근처 숙소"]
def test_제목_키워드는_유형_레인의_코어를_고른다():
merchant = seo_keywords.build_merchant("place-1", _snapshot())
assert seo_keywords.select(MATCH, merchant)["titleKeyword"] == "군산 독채펜션"
def test_제목에는_예약_추천과_지역_없는_단어를_쓰지_않는다():
"""검증: 유형 레인 코어가 `… 예약` 이고, 지역명이 없는 태그가 섞였다.
기대결과: 둘을 건너뛰고 `군산 펜션 독채` 상호 옆에서 광고 문구가 되거나 지역 신호를 잃지 않는다."""
merchant = seo_keywords.build_merchant("place-1", _snapshot())
result = {"matches": [], "byLane": [{"key": "type", "items": [
_kw("군산 독채펜션 예약", "코어", intent="transactional"),
_kw("독채펜션", "태그"),
_kw("군산 펜션 독채", "시설"),
]}]}
assert seo_keywords.select(result, merchant) == {"keywords": [], "titleKeyword": "군산 펜션 독채"}
def test_업종어뿐인_단어는_싣지_않는다():
"""검증: 낱말이 전부 업종어·"근처" 류인 추천(실측: 실제 발행에서 `숙소` 가 메타 한 칸을 차지했다).
기대결과: 뺀다 어느 가게에나 붙는 단어라 가게를 설명하지 못한다. 자료 낱말이 섞이면 남긴다."""
merchant = seo_keywords.build_merchant("place-1", _snapshot())
result = {"matches": [_kw("숙소", "태그"), _kw("펜션 예약", "의도"), _kw("원도심", "태그")], "byLane": []}
assert seo_keywords.select(result, merchant)["keywords"] == ["원도심"]
def test_표기만_다른_중복은_하나로():
merchant = seo_keywords.build_merchant("place-1", _snapshot())
result = {"matches": [_kw("군산 독채펜션", "코어"), _kw("군산 독채 펜션", "태그")], "byLane": []}
assert seo_keywords.select(result, merchant)["keywords"] == ["군산 독채펜션"]
# ── 클라이언트 ────────────────────────────────────────────────────────────
def _response(status, body, url):
return httpx.Response(status, json=body, request=httpx.Request("POST", url))
@pytest.fixture
def ontology_url(monkeypatch):
monkeypatch.setattr(site_ontology.external_api_config, "site_ontology_url", "http://onto.test/")
def _fake_ontology(sent, *, reject_region=False, resolve=True):
async def fake_post(self, url, json=None, **kwargs):
sent.append((url, json))
if url.endswith(site_ontology.PUBLISH_PATH):
if reject_region and json.get("regionId"):
# 실측: region 표에 없는 키 → 외래키 위반 → 500
return _response(500, {"statusCode": 500, "message": "Internal server error"}, url)
return _response(201, {"merchant": {"external_id": json["externalId"]}, "generation": "skipped"}, url)
resolved = {"externalId": json["query"]} if resolve else None
return _response(201, {**MATCH, "resolved": resolved}, url)
return fake_post
async def test_업체를_저장한_뒤_그_업체로_추천을_받는다(ontology_url, monkeypatch):
sent = []
monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology(sent))
merchant = seo_keywords.build_merchant("place-1", _snapshot())
await site_ontology.match_for_merchant(merchant)
assert [url for url, _ in sent] == ["http://onto.test/v1/merchants/publish", "http://onto.test/v1/match"]
# ★ generate:false 가 빠지면 SiteOntology 가 LLM 키워드 생성을 큐에 넣는다.
assert sent[0][1] == {**merchant, "generate": False}
assert sent[1][1] == {"query": "place-1", "limit": site_ontology.DEFAULT_LIMIT}
async def test_지역_키가_거절되면_지역_없이_다시_보낸다(ontology_url, monkeypatch):
sent = []
monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology(sent, reject_region=True))
merchant = seo_keywords.build_merchant("place-1", _snapshot())
await site_ontology.match_for_merchant(merchant)
publishes = [body for url, body in sent if url.endswith(site_ontology.PUBLISH_PATH)]
assert [b["regionId"] for b in publishes] == ["kr.jeonbuk.gunsan", None]
assert sent[-1][0].endswith(site_ontology.MATCH_PATH)
async def test_업체가_해석되지_않은_추천은_쓰지_않는다(ontology_url, monkeypatch):
"""검증: match 가 resolved=null 로 201 을 준다(입력 문자열로 검색한 결과).
기대결과: 실패로 본다 결과는 남의 동네 단어다(실측: `나운동 숙소`)."""
monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology([], resolve=False))
merchant = seo_keywords.build_merchant("place-1", _snapshot())
with pytest.raises(site_ontology.SiteOntologyError):
await site_ontology.match_for_merchant(merchant)
assert await seo_keywords.fetch("place-1", _snapshot()) is None
async def test_주소가_비면_부르지_않는다(monkeypatch):
monkeypatch.setattr(site_ontology.external_api_config, "site_ontology_url", "")
async def boom(self, url, json=None, **kwargs):
raise AssertionError("설정이 비었는데 SiteOntology 를 불렀다")
monkeypatch.setattr(httpx.AsyncClient, "post", boom)
assert await seo_keywords.fetch("place-1", _snapshot()) is None
async def test_연결이_안_되면_예외를_올리지_않는다(ontology_url, monkeypatch):
async def refused(self, url, json=None, **kwargs):
raise httpx.ConnectError("connection refused", request=httpx.Request("POST", url))
monkeypatch.setattr(httpx.AsyncClient, "post", refused)
assert await seo_keywords.fetch("place-1", _snapshot()) is None
# ── 발행 한 바퀴 ───────────────────────────────────────────────────────────
REQUIRED = {
"check_in_time": "15:00", "check_out_time": "11:00",
"cancel_policy": "이용 7일 전 100% 환불, 3일 전 50% 환불",
"cooking_allowed": "true", "pet_allowed": "false",
}
async def _published_place(client, h, db_engine):
pid = (await client.post("/v1/place", headers=h, json={"name": "하조대독채", "category": 1})).json()["place"]["place_id"]
await client.post(f"/v1/place/{pid}/verify", headers=h, json={
"source": 2, "road_address": f"강원특별자치도 양양군 현북면 하조대3길 {uuid.uuid4().hex[:3]}",
"phone": "033-672-0000", "latitude": "38.0219217", "longitude": "128.7221449",
})
for k, v in {**REQUIRED, "intro": "하조대 해변 도보 3분 거리의 독채 펜션입니다.", "facilities": "독채"}.items():
await client.post(f"/v1/place/{pid}/fact", headers=h, json={"key": k, "value": v})
async with db_engine.begin() as c:
await c.execute(
text("INSERT INTO place_photos (media_id, place_id, url, origin_url, source_type, status, alt_text, label, sort_order) "
"VALUES (:m,:p,:u,:u,:s,:st,:a,:l,0)"),
{"m": uuid.uuid4(), "p": uuid.UUID(pid), "u": f"https://cdn.test/{uuid.uuid4().hex[:6]}.jpg",
"s": SourceType.CRAWL.value, "st": MediaStatus.APPROVED.value, "a": "2층 목조 건물 외관", "l": "외관"},
)
return pid
async def _build(client, h, pid) -> dict:
job_id = (await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True})).json()["job_id"]
assert await Worker("w", JobQueue(), build_handler(), job_deadline_sec=60).process_one() is True
job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]
assert job["status"] == JobStatus.DONE.value, job.get("last_error")
return job["result"]
async def test_발행_payload_에_키워드가_실린다(auth_headers, client, db_engine, ontology_url, monkeypatch):
"""검증: SiteOntology 가 켜진 채로 숙박 사업장을 발행한다.
기대결과: 스냅샷으로 만든 프로필이 나가고, 거른 키워드가 payload.seo 실린다."""
h = await auth_headers("u1")
pid = await _published_place(client, h, db_engine)
sent = []
async def fake_match(merchant, limit=site_ontology.DEFAULT_LIMIT):
sent.append(merchant)
return {"resolved": {"externalId": merchant["externalId"]},
"matches": [_kw("양양 독채펜션", "코어"), _kw("양양 독채 마당 펜션", "롱테일")],
"byLane": [{"key": "type", "items": [_kw("양양 독채펜션", "코어")]}]}
monkeypatch.setattr(site_ontology, "match_for_merchant", fake_match)
r = await _build(client, h, pid)
assert r["published"] is True
assert sent[0]["externalId"] == pid
assert sent[0]["regionId"] == "kr.gangwon.yangyang"
assert "독채" in sent[0]["profile"]["features"]
payload = json.loads(open(r["payload_path"], encoding="utf-8").read())
assert payload["seo"] == {"keywords": ["양양 독채펜션"], "titleKeyword": "양양 독채펜션"}
assert r["seo"] == payload["seo"]
async def test_SiteOntology_가_죽어도_발행된다(auth_headers, client, db_engine, ontology_url, monkeypatch):
h = await auth_headers("u1")
pid = await _published_place(client, h, db_engine)
async def down(merchant, limit=site_ontology.DEFAULT_LIMIT):
raise site_ontology.SiteOntologyError("ConnectError: connection refused")
monkeypatch.setattr(site_ontology, "match_for_merchant", down)
r = await _build(client, h, pid)
assert r["published"] is True
assert "seo" not in r
site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()
assert site["site"]["status"] == SiteStatus.PUBLISHED.value
payload = json.loads(open(r["payload_path"], encoding="utf-8").read())
assert "seo" not in payload

View File

@ -55,6 +55,21 @@ export interface SitePayload {
/** 템플릿 — 색/서체/섹션 순서. 관리자 에디터가 정한 값이 그대로 온다. */
theme: SiteTheme;
/**
* SiteOntology ** ** (services/seo_keywords).
*
* . payload··SiteOntology , · .
* schemaVersion .
*/
seo?: SiteSeo;
}
export interface SiteSeo {
/** `<meta name="keywords">` 로 나간다. SiteOntology 융합 순위 그대로, 최대 10개. */
keywords: string[];
/** 제목의 업종어 자리(`<상호> · <이 값>`). 시·군 이름을 품은 유형 키워드. 없으면 예전 제목. */
titleKeyword?: string;
}
export interface SiteMeta {

View File

@ -145,6 +145,13 @@ export function renderHead({payload, meta, scriptSrc, cssHrefs = []}: HeadOption
' <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />',
` <title>${escapeHtml(meta.title)}</title>`,
tag('meta', {name: 'description', content: meta.description}),
/*
* SiteOntology . ,
* title (meta.ts homeTitle).
* . tag() ****
* `<meta name="keywords" />` (meta.test.ts ).
*/
...(meta.keywords ? [tag('meta', {name: 'keywords', content: meta.keywords})] : []),
tag('link', {rel: 'canonical', href: meta.canonical}),
// 색인을 막을 이유가 없다 — 이 사이트는 색인되라고 존재한다.
tag('meta', {

View File

@ -0,0 +1,50 @@
/**
* SiteOntology · .
*
* (services/seo_keywords) payload.seo .
* **** ,
* payload( ) head .
*/
import {describe, expect, it} from 'vitest';
import type {SitePayload} from '@o2o/shared';
import {MOONLIGHT_STAY_PAYLOAD} from '../fixtures/moonlight-stay';
import {renderHead} from './head';
import {homeMeta} from './meta';
function withSeo(seo: SitePayload['seo']): SitePayload {
return {...MOONLIGHT_STAY_PAYLOAD, seo};
}
describe('homeMeta — 제목', () => {
it('대표 키워드가 있으면 업종어 자리를 대신한다', () => {
const meta = homeMeta(withSeo({keywords: [], titleKeyword: '애월 독채펜션'}));
expect(meta.title).toBe('달빛스테이 제주 · 애월 독채펜션');
});
it('없으면 예전 제목 그대로다', () => {
expect(homeMeta(withSeo({keywords: ['애월 독채펜션']})).title).toBe(homeMeta(MOONLIGHT_STAY_PAYLOAD).title);
expect(homeMeta(MOONLIGHT_STAY_PAYLOAD).title).toContain('숙소');
});
it('15자보다 짧아지면 예전 제목으로 떨어진다 — Bing 이 Title too short 로 잡는다', () => {
const meta = homeMeta(withSeo({keywords: [], titleKeyword: '독채'}));
expect(meta.title).toBe(homeMeta(MOONLIGHT_STAY_PAYLOAD).title);
});
});
describe('renderHead — keywords 메타', () => {
it('키워드를 쉼표로 이어 싣고 이스케이프한다', () => {
const payload = withSeo({keywords: ['애월 독채펜션', '고내리 "바다" 숙소']});
const head = renderHead({payload, meta: homeMeta(payload)});
expect(head).toContain('<meta name="keywords" content="애월 독채펜션, 고내리 &quot;바다&quot; 숙소" />');
});
it('키워드가 없으면 태그를 만들지 않는다', () => {
const head = renderHead({payload: MOONLIGHT_STAY_PAYLOAD, meta: homeMeta(MOONLIGHT_STAY_PAYLOAD)});
expect(head).not.toContain('name="keywords"');
const empty = withSeo({keywords: []});
expect(renderHead({payload: empty, meta: homeMeta(empty)})).not.toContain('name="keywords"');
});
});

View File

@ -19,6 +19,8 @@ export interface PageMeta {
canonical: string;
ogImage?: string;
ogImageAlt?: string;
/** `<meta name="keywords">`. SiteOntology 키워드가 없으면 비어 태그를 만들지 않는다. */
keywords?: string;
}
function url(payload: SitePayload, ...parts: string[]): string {
@ -128,15 +130,28 @@ const CATEGORY_WORD: Record<PlaceCategory, string> = {
[PlaceCategory.CLINIC]: '의원',
};
/** Bing 웹마스터도구가 이보다 짧은 제목을 Error(Title too short)로 잡는다. */
const MIN_TITLE_LENGTH = 15;
/**
* `<상호> · <지역> <업종>`.
*
* `<상호> · <시군구>` . "스테이,머뭄 · 군산시" 12 Bing
* **15 Error(Title too short)** .
* "군산 숙소" .
* SiteOntology `<상호> · <키워드>` (2026-09-14).
* "숙소" "군산 숙소" .
* · (services/seo_keywords) .
* 15 .
*/
function homeTitle(payload: SitePayload): string {
const {place} = payload;
const keyword = payload.seo?.titleKeyword?.trim();
if (keyword) {
const title = `${place.name} · ${keyword}`;
if (title.length >= MIN_TITLE_LENGTH) return title;
}
const region = place.addressRegion && place.addressRegion !== localName(payload)
? place.addressRegion
: undefined;
@ -144,6 +159,12 @@ function homeTitle(payload: SitePayload): string {
return [place.name, where].filter(Boolean).join(' · ');
}
/** `<meta name="keywords">` 값. 키워드가 없으면 undefined — head 가 태그를 만들지 않는다. */
export function metaKeywords(payload: SitePayload): string | undefined {
const words = (payload.seo?.keywords ?? []).map((word) => word.trim()).filter(Boolean);
return words.length > 0 ? words.join(', ') : undefined;
}
export function homeMeta(payload: SitePayload): PageMeta {
const image = primaryImage(payload);
return {
@ -152,5 +173,6 @@ export function homeMeta(payload: SitePayload): PageMeta {
canonical: url(payload),
ogImage: image?.url,
ogImageAlt: image?.alt,
keywords: metaKeywords(payload),
};
}