"""발행 사이트 메타 태그용 검색 키워드 — SiteOntology 추천을 이 가게의 확인된 자료로 한 번 더 거른다. 스냅샷 → 업체 프로필(build_merchant) → SiteOntology publish + match → 거르기(select) → snapshot["seo"] → payload.seo → · 의 업종어 자리 ★ 스냅샷에 싣는다(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