숙박 사이트를 빌드할 때 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
322 lines
16 KiB
Python
322 lines
16 KiB
Python
"""발행 사이트 메타 태그 키워드(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
|