o2o-site-AEO/solution/backend/services/seo_keywords.py
민헌 b2c8bb033e [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
2026-09-14 14:38:24 +09:00

295 lines
15 KiB
Python

"""발행 사이트 메타 태그용 검색 키워드 — 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