diff --git a/.env.example b/.env.example index 045f3f4..2491fc8 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,11 @@ TOUR_API_KEY= # 그래도 API 가 필수로 요구하는 필드라 값을 채워 보낸다. SUNO_API_KEY= SUNO_CALLBACK_URL=https://example.com/api/suno/callback +# 발행 사이트 메타 키워드(keywords · 제목)를 받아 올 SiteOntology 주소(o2o-site-ontology, 기본 :3100). +# 비우면 그 단계만 건너뛴다 — 제목·메타가 예전 그대로 나간다. +# ★ 워커가 부르는 주소다. compose 로 띄우면 컨테이너 안에서 보는 주소(http://host.docker.internal:3100), +# 백엔드를 네이티브로 돌리면 http://127.0.0.1:3100 +SITE_ONTOLOGY_URL= # 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 46ef371..1d7f7f3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -37,7 +37,10 @@ React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포 ``` BUILD 잡 (worker) ─ services/build_service.py:99 run_build() - ├ build_snapshot → site_versions 행 insert (원본 데이터, JSONB) + ├ build_snapshot → 원본 데이터(JSONB) + ├ seo_keywords.fetch() → SiteOntology 추천을 이 가게 자료로 거른 키워드 → snapshot["seo"] + │ (숙박만 · 설정 없거나 실패하면 생략 · 발행은 계속) + │ → site_versions 행 insert ├ 1차 게이트 (DB 사실 기준) → publish_gate.evaluate() ├ site_payload.emit_payload() → out/payloads/.json ★ 백엔드의 유일한 산출물 │ diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 1b63c4d..3e1bcf1 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,37 @@ --- +## 2026-09-14 — 발행 사이트 제목·keywords 메타에 SiteOntology 키워드를 싣는다 + +**무슨 일** — 숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를 +받고, 거른 결과를 `` 와 제목 업종어 자리에 싣는다. +``` +스냅샷 → 프로필(확인된 fact · 주소 · 발행되는 주변 관광지) + → POST /v1/merchants/publish (generate:false) → POST /v1/match (query=place_id) + → 거르기 → snapshot["seo"] → payload.seo + → 스테이,머뭄 · 군산 독채펜션 · +``` + +**★ 거르기가 필요한 이유 (실측)** — 스테이머뭄 프로필로 받은 추천 10건 중 `군산 독채 마당 펜션`· +`군산 독채 복층 펜션`·`군산 커플 프라이빗 펜션` 이 status=ok 로 왔다. SiteOntology 의 사실 필터는 수용 인원과 +일부 시설만 보기 때문이다. 사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다. +→ **키워드의 모든 낱말이 이 가게 자료에 있어야** 싣는다. 이 규칙 하나로 셋이 같이 걸리고, 10건이 4건이 됐다. + 제목에는 `예약`·`추천` 이 붙은 것과 시·군 이름이 없는 것도 뺀다. 규칙의 단일 출처는 `services/seo_keywords.py`. + +**★ SiteOntology 쪽 함정 (실측)** +- region 표에 없는 `regionId` 를 보내면 **500**(외래키 위반). 표 내용은 적재한 데이터셋에 따라 달라 우리가 모른다 + → 500 이면 지역 없이 한 번 더 보낸다. +- 해석되지 않은 `query` 에도 **201** 로 입력 문자열 검색 결과를 준다(`나운동 숙소` …) → `resolved` 가 + 우리 place_id 가 아니면 버린다. + +**경계** — SiteOntology 는 **수정하지 않았다**. 설정(`SITE_ONTOLOGY_URL`)이 비면 호출하지 않고, 실패하면 +키워드 없이 예전 제목으로 발행한다. 키워드는 스냅샷에 실려 `site_versions.snapshot` 이 곧 발행 기록이다. + +**남은 것** — SiteOntology 매칭은 지역으로 거르지 않는다. 지금은 낱말 대조가 다른 지역 단어를 막지만, +운영에 전국 데이터가 들어가면 SiteOntology 에 지역 필터를 넣는 것이 맞다. + +--- + ## 2026-09-11 — 발행하면 이 숙소의 노래가 한 곡 생긴다 (가사 Gemini → 작곡 Suno) **무슨 일** — `/s/stay` 시안에는 헤더에 노래 플레이어가 있는데, 그건 손으로 채운 목업이라 diff --git a/solution/backend/config/config_models.py b/solution/backend/config/config_models.py index 245856e..70e692c 100644 --- a/solution/backend/config/config_models.py +++ b/solution/backend/config/config_models.py @@ -136,6 +136,9 @@ class ExternalApiConfig(BaseSettings): # ★ 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다. # 그래도 API 가 필수로 요구하는 필드라 값을 들고 있는다(services/external/suno.py 주석). suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL") + # 발행 사이트 메타 키워드(keywords · 제목)를 받아 오는 사내 서비스(o2o-site-ontology). 비면 그 단계만 + # 건너뛴다 — 제목·메타가 예전 그대로 나간다(services/seo_keywords). + site_ontology_url: str = Field("", validation_alias="SITE_ONTOLOGY_URL") # .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다. diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index 89ced65..e6a5854 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -29,7 +29,16 @@ from common.logger import LOG from common.utils.gtime import GTime from crud.site_crud import SiteCRUD from crud.place_crud import PlaceCRUD -from services import azure_static, indexnow, publish_gate, render_report, site_payload, site_thumbnail, song_service +from services import ( + azure_static, + indexnow, + publish_gate, + render_report, + seo_keywords, + site_payload, + site_thumbnail, + song_service, +) from services.local_content_service import LocalContentService from services.site_payload import emit_payload from services.snapshot import build_snapshot @@ -157,6 +166,19 @@ async def run_build(job: dict) -> dict: snapshot = await build_snapshot(place) + # ★ 메타 태그용 검색 키워드(SiteOntology). **스냅샷에 싣는다** — payload 는 스냅샷만 보고 만들고, + # "이 버전에 어떤 키워드가 나갔나" 가 site_versions.snapshot 에 남는다(services/seo_keywords 머리주석). + # ★ 실패해도 빌드는 계속한다. 주변 정보·노래와 같은 규칙이다 — 키워드 없이 예전 제목·메타로 나간다. + # ★ 재빌드(publish=False)에도 부른다. 로컬 임베딩이라 비용이 없고, 재빌드한 버전과 발행한 버전의 + # 제목이 갈리면 "눌러 본 것과 나간 것이 다르다" 가 된다. + seo: dict | None = None + try: + seo = await seo_keywords.fetch(place_id, snapshot) + except Exception as ex: # noqa: BLE001 — 키워드 실패가 발행을 죽이면 안 된다 + LOG.w(f"[build] place={place_id} 검색 키워드 실패(키워드 없이 발행): {type(ex).__name__}: {ex}") + if seo: + snapshot["seo"] = seo + v_err, version_no = await DB_SESSION_MNG.execute_lambda( site_versions.DBType(), DBWRType.DB_READ.value, lambda s: _site_crud.next_version_no(s, site.site_id) ) @@ -180,6 +202,9 @@ async def run_build(job: dict) -> dict: # 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다. if song_result is not None: result["song"] = song_result + # "제목이 왜 예전 그대로인가" 도 같다 — 키워드가 실렸으면 잡 결과에 보인다(없으면 로그의 [seo] 줄). + if seo is not None: + result["seo"] = seo now = GTime.UTC() async def _fail(reason: str, gate: publish_gate.GateResult | None = None, extra: dict | None = None): diff --git a/solution/backend/services/external/site_ontology.py b/solution/backend/services/external/site_ontology.py new file mode 100644 index 0000000..03ab606 --- /dev/null +++ b/solution/backend/services/external/site_ontology.py @@ -0,0 +1,87 @@ +"""SiteOntology(o2o-site-ontology) 클라이언트 — 이 가게에 맞는 검색 키워드를 받아 온다. + +SiteOntology 는 펜션 SEO/AEO 키워드 사전(pgvector)을 들고, 업체 프로필에 맞는 단어를 골라 주는 +사내 서비스다. 여기서는 창구 두 개만 쓴다(실측 2026-09-14, 로컬 :3100). + + POST /v1/merchants/publish {externalId, name, industryId, regionId, description, profile, generate:false} + → 201 {merchant, generation: "skipped"} 업체를 저장만 한다. 키워드는 주지 않는다 + POST /v1/match {query: , limit} + → 201 {resolved, lanes, matches[], byLane[], excluded[]} 저장된 프로필로 추천한다 + +★ 두 번 부르는 이유: "프로필을 주면 키워드를 돌려주는" 창구가 한 번에는 없다. match 는 query 문자열만 + 받고, externalId 를 넣으면 저장된 업체로 해석해 그 프로필로 추천한다. 그래서 먼저 publish 로 프로필을 + 이번 빌드의 스냅샷 값으로 맞춘다. +★ generate:false 를 반드시 보낸다. 빠뜨리면 SiteOntology 가 LLM 키워드 생성을 큐에 넣는다 — + 그 결과는 우리가 쓰지 않는 창구(/seo)로만 나가고, 로컬은 mock LLM 이라 가짜 단어가 사전에 쌓인다. +★ regionId 가 SiteOntology 의 region 표에 없으면 **500** 이다(외래키 위반, 실측). 그 표는 적재한 데이터셋에 + 따라 달라서(군산만 / 전국 54개) 우리가 알 수 없다 — 500 이면 regionId 를 비워 한 번 더 보낸다. + 지역이 비면 유형 추천에서 "군산" 이 빠져 품질이 떨어지지만(실측: 1위가 `독채펜션`), 없는 것보다 낫고 + 지역 없는 단어는 호출측 거르기(seo_keywords)가 제목에서 뺀다. +★ externalId 가 해석되지 않으면 match 는 에러가 아니라 **입력 문자열 자체로 검색한 결과**를 201 로 준다 + (실측: "no-such-place-id" → `나운동 숙소`·`선유도 숙소 예약 언제 해야 하나요`). 그걸 쓰면 남의 동네 단어가 + 나간다 — resolved 가 우리 externalId 가 아니면 실패로 본다. +★ 실패는 발행을 막지 않는다. 여기서는 SiteOntologyError 로 올리고, 호출측이 잡아 키워드 없이 굽는다. +""" + +import httpx + +from common.logger import LOG +from config.server_configs import external_api_config + +# 로컬 임베딩 모델이라 호출당 1~2초다. 넉넉히 잡되 발행 잡 데드라인(900s)을 잡아먹지 않게 끊는다. +TIMEOUT_SEC = 15.0 +# 거르기(seo_keywords)에서 절반 넘게 떨어진다(실측: 10건 → 4건). 메타 태그 10개를 채우려면 넉넉히 받는다. +DEFAULT_LIMIT = 40 + +PUBLISH_PATH = "/v1/merchants/publish" +MATCH_PATH = "/v1/match" + + +class SiteOntologyError(RuntimeError): + """SiteOntology 호출 실패(네트워크·타임아웃·4xx/5xx·업체 미해석). 발행은 계속된다.""" + + +def base_url() -> str: + return (external_api_config.site_ontology_url or "").strip().rstrip("/") + + +def is_configured() -> bool: + return bool(base_url()) + + +async def _post(client: httpx.AsyncClient, path: str, body: dict) -> dict: + res = await client.post(f"{base_url()}{path}", json=body) + if res.status_code >= 400: + raise SiteOntologyError(f"{path} HTTP {res.status_code}: {res.text[:200]}") + try: + data = res.json() + except ValueError as ex: + raise SiteOntologyError(f"{path} 응답이 JSON 이 아니다: {res.text[:200]}") from ex + if not isinstance(data, dict): + raise SiteOntologyError(f"{path} 응답 모양이 다르다: {type(data).__name__}") + return data + + +async def match_for_merchant(merchant: dict, limit: int = DEFAULT_LIMIT) -> dict: + """업체를 저장하고, 그 업체로 해석된 추천 결과(/v1/match 응답)를 돌려준다. + + merchant 는 publish 요청 본문이다(generate 는 여기서 붙인다). 실패하면 SiteOntologyError.""" + body = {**merchant, "generate": False} + try: + async with httpx.AsyncClient(timeout=TIMEOUT_SEC) as client: + try: + await _post(client, PUBLISH_PATH, body) + except SiteOntologyError as ex: + if not body.get("regionId"): + raise + LOG.w(f"[site-ontology] regionId={body['regionId']} 로 저장하지 못했다 — 지역 없이 다시 보낸다: {ex}") + body = {**body, "regionId": None} + await _post(client, PUBLISH_PATH, body) + result = await _post(client, MATCH_PATH, {"query": merchant["externalId"], "limit": limit}) + except httpx.HTTPError as ex: + raise SiteOntologyError(f"{type(ex).__name__}: {ex}") from ex + + resolved = result.get("resolved") or {} + if resolved.get("externalId") != merchant["externalId"]: + raise SiteOntologyError(f"업체가 해석되지 않았다 — externalId={merchant['externalId']}") + return result diff --git a/solution/backend/services/seo_keywords.py b/solution/backend/services/seo_keywords.py new file mode 100644 index 0000000..a9d2e13 --- /dev/null +++ b/solution/backend/services/seo_keywords.py @@ -0,0 +1,294 @@ +"""발행 사이트 메타 태그용 검색 키워드 — 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 diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index e5a7932..e16f2d7 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -975,9 +975,26 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict: ], "narrative": narrative, "theme": theme, + # ★ 검색 키워드(SiteOntology). 스냅샷에 있을 때만 싣는다 — 옛 스냅샷·SiteOntology 가 꺼진 빌드에는 없고, + # 그때 렌더러는 제목·메타를 예전 그대로 굽는다(solution/site/src/seo/meta.ts). + **_seo_entry(snapshot.get("seo")), } +def _seo_entry(value) -> dict: + """스냅샷의 seo(services/seo_keywords) → payload 의 `seo`. 없으면 키 자체를 만들지 않는다. + + ★ 여기서 다시 거르지 않는다 — 이 가게 자료로 거르는 곳은 seo_keywords 한 곳이다. 모양만 확인한다. + ★ 빈 seo 를 만들지 않는다. 빈 배열은 '받았는데 비었다' 로 읽힌다(itineraries 와 같은 규칙).""" + if not isinstance(value, dict): + return {} + keywords = [_text(k) for k in value.get("keywords") or [] if _text(k)] + title = _text(value.get("titleKeyword")) + if not keywords and not title: + return {} + return {"seo": {"keywords": keywords, **({"titleKeyword": title} if title else {})}} + + def _as_float(value): try: return float(value) if value not in (None, "") else None diff --git a/solution/backend/tests/test_seo_keywords.py b/solution/backend/tests/test_seo_keywords.py new file mode 100644 index 0000000..af53bf2 --- /dev/null +++ b/solution/backend/tests/test_seo_keywords.py @@ -0,0 +1,321 @@ +"""발행 사이트 메타 태그 키워드(SiteOntology). + +이 경로가 절대 하면 안 되는 것: + - 이 가게 자료에 없는 낱말이 든 키워드를 메타 태그·제목에 싣는 것 + (확인 안 된 시설 · 다른 권역 · 가격 주장) + - SiteOntology 실패로 발행을 막는 것 — 키워드는 곁들이다 + - 해석되지 않은 업체의 추천(입력 문자열로 검색한 결과)을 쓰는 것 + - 숙박이 아닌 업종에 펜션 키워드를 붙이는 것 + +★ MATCH 의 matches 1~10 · byLane 은 실제 응답에서 옮겼다 — 2026-09-14 로컬 SiteOntology(:3100) 에 + 스테이머뭄 프로필(features 독채·프라이빗, audiences 커플·가족, nearby 말랭이마을·동국사)을 보낸 결과. + 11~13 은 같은 사전에 실제로 있는 단어로, 거르기 규칙을 보려고 붙였다. +""" +import json +import uuid + +import httpx +import pytest +from sqlalchemy import text + +from common.enums import JobStatus, LocalContentType, MediaStatus, PlaceCategory, SiteStatus, SourceType +from crud.job_crud import JobQueue +from services import seo_keywords +from services.external import site_ontology +from worker.handlers import build_handler +from worker.runner import Worker + + +def _kw(canonical, category, intent="local", status="ok"): + return {"canonical": canonical, "category": category, "intent": intent, "kind": "keyword", "status": status} + + +MATCH = { + "mode": "fusion", + "matches": [ + _kw("군산 펜션 독채", "시설"), + _kw("군산 독채펜션", "코어"), + _kw("군산 독채 마당 펜션", "롱테일"), + _kw("군산 독채 복층 펜션", "롱테일"), + _kw("군산 커플 프라이빗 펜션", "롱테일"), + _kw("군산 독채펜션 예약", "코어", intent="transactional"), + _kw("군산에 독채 펜션 있나요", "질문형", intent="informational"), + _kw("군산 가족 독채펜션", "동반자"), + _kw("말랭이마을 근처 숙소", "관광지"), + _kw("군산 프라이빗한 독채", "분위기"), + _kw("선유도 독채펜션", "권역"), + _kw("군산 펜션 최저가", "의도", intent="transactional"), + _kw("군산 주차 펜션", "시설", status="hold"), + ], + "byLane": [ + {"key": "type", "label": "유형", "items": [ + _kw("군산 펜션 독채", "시설"), + _kw("군산 독채펜션", "코어"), + _kw("군산 독채 마당 펜션", "롱테일"), + ]}, + {"key": "place", "label": "위치", "items": [_kw("말랭이마을 근처 숙소", "관광지")]}, + ], +} + + +def _fact(key, value, unit_id=None): + return {"key": key, "value": value, "scope": "unit" if unit_id else "place", "unit_id": unit_id, "status": 3} + + +def _snapshot(**place): + return { + "place": { + "name": "스테이,머뭄", "category": PlaceCategory.LODGING.value, + "road_address": "전북특별자치도 군산시 절골길 18", "address": None, **place, + }, + "facts": [ + _fact("intro", "군산 원도심 말랭이마을 옆 독채 스테이.\n두 번째 문단은 싣지 않는다."), + _fact("bbq_available", "false"), + _fact("parking", "true"), + _fact("room_type", "독채", unit_id="u1"), + _fact("max_capacity", "4", unit_id="u1"), + _fact("max_capacity", "2", unit_id="u2"), + ], + "local": {"contents": [ + {"content_type": LocalContentType.ATTRACTION.value, "title": "말랭이마을", "body": {"name": "말랭이마을"}}, + {"content_type": LocalContentType.RESTAURANT.value, "title": "이성당", "body": {"name": "이성당"}}, + {"content_type": LocalContentType.ATTRACTION.value, "title": "동국사", "body": {}}, + ]}, + } + + +# ── 요청 만들기 ───────────────────────────────────────────────────────────── +def test_스냅샷으로_업체_프로필을_만든다(): + """검증: 확인된 fact·지역 정보가 든 숙박 스냅샷. + 기대결과: 있음(true)은 features, 모름(fact 없음)은 unverified, 없음(false)은 어디에도 없다.""" + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + + assert merchant == { + "externalId": "place-1", + "name": "스테이,머뭄", + "industryId": "stay.pension", + "regionId": "kr.jeonbuk.gunsan", + "description": "군산 원도심 말랭이마을 옆 독채 스테이.", + "profile": { + "address": "전북특별자치도 군산시 절골길 18", + "capacity": {"max": 4}, + "features": ["주차", "독채"], + # 음식점(이성당)은 주변 관광지가 아니다. + "nearby": ["말랭이마을", "동국사"], + # ★ 바베큐는 false(없음)라 여기 없다 — 넣으면 SiteOntology 가 배제 대신 보류한다. + "unverified": ["애견동반", "조식"], + }, + } + + +def test_숙박이_아니면_부르지_않는다(): + assert seo_keywords.build_merchant("place-1", _snapshot(category=PlaceCategory.CAFE.value)) is None + + +def test_표에_없는_지역은_지역을_비운다(): + """검증: SiteOntology 지역 표(54개)에 없는 시·군. + 기대결과: 지어내지 않고 None — 없는 키를 보내면 SiteOntology 가 500 이다.""" + merchant = seo_keywords.build_merchant("place-1", _snapshot(road_address="서울특별시 강남구 테헤란로 1")) + assert merchant["regionId"] is None + + +def test_같은_이름의_시군은_시도로_가른다(): + assert seo_keywords.region_key("강원특별자치도 고성군 토성면 1") == "kr.gangwon.goseong" + assert seo_keywords.region_key("경상남도 고성군 고성읍 1") is None + + +# ── 거르기 ───────────────────────────────────────────────────────────────── +def test_자료가_뒷받침하는_키워드만_남긴다(): + """검증: 실제 추천 결과를 이 가게 자료(주차·독채·원도심·말랭이마을·동국사)로 거른다. + 기대결과: 마당·복층·커플·가족·프라이빗(확인 안 됨), 선유도(다른 권역), 최저가(가격 주장), + 질문형, 보류(hold)가 모두 빠진다. 순위는 SiteOntology 순서 그대로.""" + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + seo = seo_keywords.select(MATCH, merchant) + + assert seo["keywords"] == ["군산 펜션 독채", "군산 독채펜션", "군산 독채펜션 예약", "말랭이마을 근처 숙소"] + + +def test_제목_키워드는_유형_레인의_코어를_고른다(): + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + assert seo_keywords.select(MATCH, merchant)["titleKeyword"] == "군산 독채펜션" + + +def test_제목에는_예약_추천과_지역_없는_단어를_쓰지_않는다(): + """검증: 유형 레인 코어가 `… 예약` 이고, 지역명이 없는 태그가 섞였다. + 기대결과: 둘을 건너뛰고 `군산 펜션 독채` — 상호 옆에서 광고 문구가 되거나 지역 신호를 잃지 않는다.""" + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + result = {"matches": [], "byLane": [{"key": "type", "items": [ + _kw("군산 독채펜션 예약", "코어", intent="transactional"), + _kw("독채펜션", "태그"), + _kw("군산 펜션 독채", "시설"), + ]}]} + assert seo_keywords.select(result, merchant) == {"keywords": [], "titleKeyword": "군산 펜션 독채"} + + +def test_업종어뿐인_단어는_싣지_않는다(): + """검증: 낱말이 전부 업종어·"근처" 류인 추천(실측: 실제 발행에서 `숙소` 가 메타 한 칸을 차지했다). + 기대결과: 뺀다 — 어느 가게에나 붙는 단어라 이 가게를 설명하지 못한다. 자료 낱말이 섞이면 남긴다.""" + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + result = {"matches": [_kw("숙소", "태그"), _kw("펜션 예약", "의도"), _kw("원도심", "태그")], "byLane": []} + assert seo_keywords.select(result, merchant)["keywords"] == ["원도심"] + + +def test_표기만_다른_중복은_하나로(): + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + result = {"matches": [_kw("군산 독채펜션", "코어"), _kw("군산 독채 펜션", "태그")], "byLane": []} + assert seo_keywords.select(result, merchant)["keywords"] == ["군산 독채펜션"] + + +# ── 클라이언트 ──────────────────────────────────────────────────────────── +def _response(status, body, url): + return httpx.Response(status, json=body, request=httpx.Request("POST", url)) + + +@pytest.fixture +def ontology_url(monkeypatch): + monkeypatch.setattr(site_ontology.external_api_config, "site_ontology_url", "http://onto.test/") + + +def _fake_ontology(sent, *, reject_region=False, resolve=True): + async def fake_post(self, url, json=None, **kwargs): + sent.append((url, json)) + if url.endswith(site_ontology.PUBLISH_PATH): + if reject_region and json.get("regionId"): + # 실측: region 표에 없는 키 → 외래키 위반 → 500 + return _response(500, {"statusCode": 500, "message": "Internal server error"}, url) + return _response(201, {"merchant": {"external_id": json["externalId"]}, "generation": "skipped"}, url) + resolved = {"externalId": json["query"]} if resolve else None + return _response(201, {**MATCH, "resolved": resolved}, url) + + return fake_post + + +async def test_업체를_저장한_뒤_그_업체로_추천을_받는다(ontology_url, monkeypatch): + sent = [] + monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology(sent)) + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + + await site_ontology.match_for_merchant(merchant) + + assert [url for url, _ in sent] == ["http://onto.test/v1/merchants/publish", "http://onto.test/v1/match"] + # ★ generate:false 가 빠지면 SiteOntology 가 LLM 키워드 생성을 큐에 넣는다. + assert sent[0][1] == {**merchant, "generate": False} + assert sent[1][1] == {"query": "place-1", "limit": site_ontology.DEFAULT_LIMIT} + + +async def test_지역_키가_거절되면_지역_없이_다시_보낸다(ontology_url, monkeypatch): + sent = [] + monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology(sent, reject_region=True)) + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + + await site_ontology.match_for_merchant(merchant) + + publishes = [body for url, body in sent if url.endswith(site_ontology.PUBLISH_PATH)] + assert [b["regionId"] for b in publishes] == ["kr.jeonbuk.gunsan", None] + assert sent[-1][0].endswith(site_ontology.MATCH_PATH) + + +async def test_업체가_해석되지_않은_추천은_쓰지_않는다(ontology_url, monkeypatch): + """검증: match 가 resolved=null 로 201 을 준다(입력 문자열로 검색한 결과). + 기대결과: 실패로 본다 — 그 결과는 남의 동네 단어다(실측: `나운동 숙소`).""" + monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology([], resolve=False)) + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + + with pytest.raises(site_ontology.SiteOntologyError): + await site_ontology.match_for_merchant(merchant) + assert await seo_keywords.fetch("place-1", _snapshot()) is None + + +async def test_주소가_비면_부르지_않는다(monkeypatch): + monkeypatch.setattr(site_ontology.external_api_config, "site_ontology_url", "") + + async def boom(self, url, json=None, **kwargs): + raise AssertionError("설정이 비었는데 SiteOntology 를 불렀다") + + monkeypatch.setattr(httpx.AsyncClient, "post", boom) + assert await seo_keywords.fetch("place-1", _snapshot()) is None + + +async def test_연결이_안_되면_예외를_올리지_않는다(ontology_url, monkeypatch): + async def refused(self, url, json=None, **kwargs): + raise httpx.ConnectError("connection refused", request=httpx.Request("POST", url)) + + monkeypatch.setattr(httpx.AsyncClient, "post", refused) + assert await seo_keywords.fetch("place-1", _snapshot()) is None + + +# ── 발행 한 바퀴 ─────────────────────────────────────────────────────────── +REQUIRED = { + "check_in_time": "15:00", "check_out_time": "11:00", + "cancel_policy": "이용 7일 전 100% 환불, 3일 전 50% 환불", + "cooking_allowed": "true", "pet_allowed": "false", +} + + +async def _published_place(client, h, db_engine): + pid = (await client.post("/v1/place", headers=h, json={"name": "하조대독채", "category": 1})).json()["place"]["place_id"] + await client.post(f"/v1/place/{pid}/verify", headers=h, json={ + "source": 2, "road_address": f"강원특별자치도 양양군 현북면 하조대3길 {uuid.uuid4().hex[:3]}", + "phone": "033-672-0000", "latitude": "38.0219217", "longitude": "128.7221449", + }) + for k, v in {**REQUIRED, "intro": "하조대 해변 도보 3분 거리의 독채 펜션입니다.", "facilities": "독채"}.items(): + await client.post(f"/v1/place/{pid}/fact", headers=h, json={"key": k, "value": v}) + async with db_engine.begin() as c: + await c.execute( + text("INSERT INTO place_photos (media_id, place_id, url, origin_url, source_type, status, alt_text, label, sort_order) " + "VALUES (:m,:p,:u,:u,:s,:st,:a,:l,0)"), + {"m": uuid.uuid4(), "p": uuid.UUID(pid), "u": f"https://cdn.test/{uuid.uuid4().hex[:6]}.jpg", + "s": SourceType.CRAWL.value, "st": MediaStatus.APPROVED.value, "a": "2층 목조 건물 외관", "l": "외관"}, + ) + return pid + + +async def _build(client, h, pid) -> dict: + job_id = (await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True})).json()["job_id"] + assert await Worker("w", JobQueue(), build_handler(), job_deadline_sec=60).process_one() is True + job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"] + assert job["status"] == JobStatus.DONE.value, job.get("last_error") + return job["result"] + + +async def test_발행_payload_에_키워드가_실린다(auth_headers, client, db_engine, ontology_url, monkeypatch): + """검증: SiteOntology 가 켜진 채로 숙박 사업장을 발행한다. + 기대결과: 스냅샷으로 만든 프로필이 나가고, 거른 키워드가 payload.seo 로 실린다.""" + h = await auth_headers("u1") + pid = await _published_place(client, h, db_engine) + sent = [] + + async def fake_match(merchant, limit=site_ontology.DEFAULT_LIMIT): + sent.append(merchant) + return {"resolved": {"externalId": merchant["externalId"]}, + "matches": [_kw("양양 독채펜션", "코어"), _kw("양양 독채 마당 펜션", "롱테일")], + "byLane": [{"key": "type", "items": [_kw("양양 독채펜션", "코어")]}]} + + monkeypatch.setattr(site_ontology, "match_for_merchant", fake_match) + r = await _build(client, h, pid) + + assert r["published"] is True + assert sent[0]["externalId"] == pid + assert sent[0]["regionId"] == "kr.gangwon.yangyang" + assert "독채" in sent[0]["profile"]["features"] + payload = json.loads(open(r["payload_path"], encoding="utf-8").read()) + assert payload["seo"] == {"keywords": ["양양 독채펜션"], "titleKeyword": "양양 독채펜션"} + assert r["seo"] == payload["seo"] + + +async def test_SiteOntology_가_죽어도_발행된다(auth_headers, client, db_engine, ontology_url, monkeypatch): + h = await auth_headers("u1") + pid = await _published_place(client, h, db_engine) + + async def down(merchant, limit=site_ontology.DEFAULT_LIMIT): + raise site_ontology.SiteOntologyError("ConnectError: connection refused") + + monkeypatch.setattr(site_ontology, "match_for_merchant", down) + r = await _build(client, h, pid) + + assert r["published"] is True + assert "seo" not in r + site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json() + assert site["site"]["status"] == SiteStatus.PUBLISHED.value + payload = json.loads(open(r["payload_path"], encoding="utf-8").read()) + assert "seo" not in payload diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index 05bb1ff..d893580 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -55,6 +55,21 @@ export interface SitePayload { /** 템플릿 — 색/서체/섹션 순서. 관리자 에디터가 정한 값이 그대로 온다. */ theme: SiteTheme; + + /** + * 검색 키워드 — SiteOntology 가 고르고 백엔드가 **이 가게 자료로 거른** 것(services/seo_keywords). + * + * ★ 선택 필드다. 옛 payload·목업·SiteOntology 가 꺼진 빌드에는 없고, 그때 제목·메타는 예전 그대로 나간다. + * 필드를 더했을 뿐 기존 모양은 그대로라 schemaVersion 은 올리지 않는다. + */ + seo?: SiteSeo; +} + +export interface SiteSeo { + /** `<meta name="keywords">` 로 나간다. SiteOntology 융합 순위 그대로, 최대 10개. */ + keywords: string[]; + /** 제목의 업종어 자리(`<상호> · <이 값>`). 시·군 이름을 품은 유형 키워드. 없으면 예전 제목. */ + titleKeyword?: string; } export interface SiteMeta { diff --git a/solution/site/src/seo/head.ts b/solution/site/src/seo/head.ts index ef0afa0..619fffa 100644 --- a/solution/site/src/seo/head.ts +++ b/solution/site/src/seo/head.ts @@ -145,6 +145,13 @@ export function renderHead({payload, meta, scriptSrc, cssHrefs = []}: HeadOption ' <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />', ` <title>${escapeHtml(meta.title)}`, tag('meta', {name: 'description', content: meta.description}), + /* + * SiteOntology 키워드. 구글은 이 태그를 순위에 쓰지 않는다 — 비용이 없어 싣는 자리이고, + * 순위에 닿는 자리는 위의 title 이다(meta.ts homeTitle). + * ★ 조건부로 넣는다. tag() 는 빈 **속성**만 빼고 태그는 만든다 — 그대로 두면 키워드가 없는 + * 모든 사이트에 `` 빈 태그가 박힌다(meta.test.ts 가 잡았다). + */ + ...(meta.keywords ? [tag('meta', {name: 'keywords', content: meta.keywords})] : []), tag('link', {rel: 'canonical', href: meta.canonical}), // 색인을 막을 이유가 없다 — 이 사이트는 색인되라고 존재한다. tag('meta', { diff --git a/solution/site/src/seo/meta.test.ts b/solution/site/src/seo/meta.test.ts new file mode 100644 index 0000000..0f8b697 --- /dev/null +++ b/solution/site/src/seo/meta.test.ts @@ -0,0 +1,50 @@ +/** + * SiteOntology 키워드 → 제목 · 메타 키워드. + * + * ★ 백엔드(services/seo_keywords)가 이미 이 가게 자료로 거른 값만 payload.seo 로 온다. + * 여기서 지키는 것은 **자리**다 — 키워드가 없거나 제목이 너무 짧아지면 예전 제목으로 떨어져야 하고, + * 키워드가 없는 옛 payload(목업 포함)는 head 가 한 글자도 바뀌면 안 된다. + */ +import {describe, expect, it} from 'vitest'; + +import type {SitePayload} from '@o2o/shared'; + +import {MOONLIGHT_STAY_PAYLOAD} from '../fixtures/moonlight-stay'; +import {renderHead} from './head'; +import {homeMeta} from './meta'; + +function withSeo(seo: SitePayload['seo']): SitePayload { + return {...MOONLIGHT_STAY_PAYLOAD, seo}; +} + +describe('homeMeta — 제목', () => { + it('대표 키워드가 있으면 업종어 자리를 대신한다', () => { + const meta = homeMeta(withSeo({keywords: [], titleKeyword: '애월 독채펜션'})); + expect(meta.title).toBe('달빛스테이 제주 · 애월 독채펜션'); + }); + + it('없으면 예전 제목 그대로다', () => { + expect(homeMeta(withSeo({keywords: ['애월 독채펜션']})).title).toBe(homeMeta(MOONLIGHT_STAY_PAYLOAD).title); + expect(homeMeta(MOONLIGHT_STAY_PAYLOAD).title).toContain('숙소'); + }); + + it('15자보다 짧아지면 예전 제목으로 떨어진다 — Bing 이 Title too short 로 잡는다', () => { + const meta = homeMeta(withSeo({keywords: [], titleKeyword: '독채'})); + expect(meta.title).toBe(homeMeta(MOONLIGHT_STAY_PAYLOAD).title); + }); +}); + +describe('renderHead — keywords 메타', () => { + it('키워드를 쉼표로 이어 싣고 이스케이프한다', () => { + const payload = withSeo({keywords: ['애월 독채펜션', '고내리 "바다" 숙소']}); + const head = renderHead({payload, meta: homeMeta(payload)}); + expect(head).toContain(''); + }); + + it('키워드가 없으면 태그를 만들지 않는다', () => { + const head = renderHead({payload: MOONLIGHT_STAY_PAYLOAD, meta: homeMeta(MOONLIGHT_STAY_PAYLOAD)}); + expect(head).not.toContain('name="keywords"'); + const empty = withSeo({keywords: []}); + expect(renderHead({payload: empty, meta: homeMeta(empty)})).not.toContain('name="keywords"'); + }); +}); diff --git a/solution/site/src/seo/meta.ts b/solution/site/src/seo/meta.ts index f70e01f..ed5fca4 100644 --- a/solution/site/src/seo/meta.ts +++ b/solution/site/src/seo/meta.ts @@ -19,6 +19,8 @@ export interface PageMeta { canonical: string; ogImage?: string; ogImageAlt?: string; + /** ``. SiteOntology 키워드가 없으면 비어 태그를 만들지 않는다. */ + keywords?: string; } function url(payload: SitePayload, ...parts: string[]): string { @@ -128,15 +130,28 @@ const CATEGORY_WORD: Record = { [PlaceCategory.CLINIC]: '의원', }; +/** Bing 웹마스터도구가 이보다 짧은 제목을 Error(Title too short)로 잡는다. */ +const MIN_TITLE_LENGTH = 15; + /** * `<상호> · <지역> <업종>`. * * ★ `<상호> · <시군구>` 였다. "스테이,머뭄 · 군산시" 는 12자인데 Bing 웹마스터도구가 * **15자 미만을 Error(Title too short)** 로 잡는다. 광역시도와 업종어를 같이 실어 * 길이도 채우고 "군산 숙소" 같은 질의에도 걸리게 한다. + * ★ SiteOntology 대표 키워드가 오면 `<상호> · <키워드>` 다 (2026-09-14). + * "숙소" 는 업종별 고정어라 "군산 숙소" 질의에만 걸린다. 키워드는 백엔드가 이 가게 자료로 거르고 + * 시·군 이름을 품은 것만 보내므로(services/seo_keywords) 지역 신호는 그대로 남는다. + * 붙였더니 15자보다 짧으면 예전 제목으로 떨어진다. */ function homeTitle(payload: SitePayload): string { const {place} = payload; + const keyword = payload.seo?.titleKeyword?.trim(); + if (keyword) { + const title = `${place.name} · ${keyword}`; + if (title.length >= MIN_TITLE_LENGTH) return title; + } + const region = place.addressRegion && place.addressRegion !== localName(payload) ? place.addressRegion : undefined; @@ -144,6 +159,12 @@ function homeTitle(payload: SitePayload): string { return [place.name, where].filter(Boolean).join(' · '); } +/** `` 값. 키워드가 없으면 undefined — head 가 태그를 만들지 않는다. */ +export function metaKeywords(payload: SitePayload): string | undefined { + const words = (payload.seo?.keywords ?? []).map((word) => word.trim()).filter(Boolean); + return words.length > 0 ? words.join(', ') : undefined; +} + export function homeMeta(payload: SitePayload): PageMeta { const image = primaryImage(payload); return { @@ -152,5 +173,6 @@ export function homeMeta(payload: SitePayload): PageMeta { canonical: url(payload), ogImage: image?.url, ogImageAlt: image?.alt, + keywords: metaKeywords(payload), }; }