여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
263 lines
11 KiB
Python
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
|