[feat] solution/backend,site: 발행 사이트 제목·keywords 메타에 SiteOntology 키워드 — 이 가게 자료로 거른 것만
숙박 사이트를 빌드할 때 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
This commit is contained in:
parent
c07e2bddf4
commit
b2c8bb033e
@ -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=
|
||||
|
||||
|
||||
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
|
||||
|
||||
@ -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/<slug>.json ★ 백엔드의 유일한 산출물
|
||||
│
|
||||
|
||||
@ -5,6 +5,37 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-14 — 발행 사이트 제목·keywords 메타에 SiteOntology 키워드를 싣는다
|
||||
|
||||
**무슨 일** — 숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를
|
||||
받고, 거른 결과를 `<meta name="keywords">` 와 제목 업종어 자리에 싣는다.
|
||||
```
|
||||
스냅샷 → 프로필(확인된 fact · 주소 · 발행되는 주변 관광지)
|
||||
→ POST /v1/merchants/publish (generate:false) → POST /v1/match (query=place_id)
|
||||
→ 거르기 → snapshot["seo"] → payload.seo
|
||||
→ <title>스테이,머뭄 · 군산 독채펜션</title> · <meta name="keywords" content="군산 펜션 독채, …">
|
||||
```
|
||||
|
||||
**★ 거르기가 필요한 이유 (실측)** — 스테이머뭄 프로필로 받은 추천 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` 시안에는 헤더에 노래 플레이어가 있는데, 그건 손으로 채운 목업이라
|
||||
|
||||
@ -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_*) 로 주입받는다.
|
||||
|
||||
@ -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):
|
||||
|
||||
87
solution/backend/services/external/site_ontology.py
vendored
Normal file
87
solution/backend/services/external/site_ontology.py
vendored
Normal file
@ -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: <externalId>, 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
|
||||
294
solution/backend/services/seo_keywords.py
Normal file
294
solution/backend/services/seo_keywords.py
Normal file
@ -0,0 +1,294 @@
|
||||
"""발행 사이트 메타 태그용 검색 키워드 — 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
|
||||
@ -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
|
||||
|
||||
321
solution/backend/tests/test_seo_keywords.py
Normal file
321
solution/backend/tests/test_seo_keywords.py
Normal file
@ -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
|
||||
@ -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 {
|
||||
|
||||
@ -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)}</title>`,
|
||||
tag('meta', {name: 'description', content: meta.description}),
|
||||
/*
|
||||
* SiteOntology 키워드. 구글은 이 태그를 순위에 쓰지 않는다 — 비용이 없어 싣는 자리이고,
|
||||
* 순위에 닿는 자리는 위의 title 이다(meta.ts homeTitle).
|
||||
* ★ 조건부로 넣는다. tag() 는 빈 **속성**만 빼고 태그는 만든다 — 그대로 두면 키워드가 없는
|
||||
* 모든 사이트에 `<meta name="keywords" />` 빈 태그가 박힌다(meta.test.ts 가 잡았다).
|
||||
*/
|
||||
...(meta.keywords ? [tag('meta', {name: 'keywords', content: meta.keywords})] : []),
|
||||
tag('link', {rel: 'canonical', href: meta.canonical}),
|
||||
// 색인을 막을 이유가 없다 — 이 사이트는 색인되라고 존재한다.
|
||||
tag('meta', {
|
||||
|
||||
50
solution/site/src/seo/meta.test.ts
Normal file
50
solution/site/src/seo/meta.test.ts
Normal file
@ -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('<meta name="keywords" content="애월 독채펜션, 고내리 "바다" 숙소" />');
|
||||
});
|
||||
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@ -19,6 +19,8 @@ export interface PageMeta {
|
||||
canonical: string;
|
||||
ogImage?: string;
|
||||
ogImageAlt?: string;
|
||||
/** `<meta name="keywords">`. SiteOntology 키워드가 없으면 비어 태그를 만들지 않는다. */
|
||||
keywords?: string;
|
||||
}
|
||||
|
||||
function url(payload: SitePayload, ...parts: string[]): string {
|
||||
@ -128,15 +130,28 @@ const CATEGORY_WORD: Record<PlaceCategory, string> = {
|
||||
[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(' · ');
|
||||
}
|
||||
|
||||
/** `<meta name="keywords">` 값. 키워드가 없으면 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),
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user