o2o-site-AEO/solution/backend/services/seo_keywords.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

263 lines
11 KiB
Python

"""발행 사이트 메타 태그용 검색 키워드 — SiteOntology 추천을 이 가게의 확인된 자료로 한 번 더 거른다."""
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)로 옮긴다 — 저쪽이 이 낱말로 보유 시설을 판정한다.
_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개).
_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 키."""
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 요청 본문."""
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:
"""모든 낱말이 자료에 있고, **자료로 확인한 낱말이 하나는 있어야** 한다."""
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:
"""제목 업종어 자리에 넣을 대표 키워드 — 유형 레인에서 고른다."""
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?}."""
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 값."""
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