숙박 사이트를 빌드할 때 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
295 lines
15 KiB
Python
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
|