[fix] solution/backend,site: 지역 이야기가 영영 안 생기던 것 + 자체 홈페이지 자동 등록

스테이,머뭄으로 실제 발행해 시안(/s/stay)과 대조한 결과에서 나온 셋이다.

★ 지역 이야기 다섯이 통째로 비어 있었다 (섹션 13 → 18)
  `has_stories()` 가 "이 지역에 이야기가 있나" 를 `kind IS NOT NULL` 로 판정했다. 그런데
  kind 는 이야기 전용 칸이 아니다 — 마이그레이션 0008 이 날씨·축제·명소·맛집에도 kind 를
  채웠다(AREA_KIND). 그래서 주변정보가 한 건이라도 들어온 지역은 이야기가 0건이어도
  "이미 있다" 로 판정돼 생성이 영영 건너뛰어졌다. 잡은 성공으로 끝나고 로그도 조용해서
  생성기가 없는 것처럼 보였다. STORY_KINDS 를 명시해서 고친다.
  실측(전북 군산시): 고친 뒤 54건 생성(가요 8·인물 10·연표 12·엽서 12·퀴즈 12),
  발행본 섹션 13 → 18, 본문 6,598자 → 10,798자.

★ 업소 자체 홈페이지를 아무도 등록하지 않고 버리고 있었다
  네이버 지역검색 응답의 `link` 가 업체 홈페이지인데(external/naver.py 머리주석이 "채널 URL
  발견에 쓸 수 있는 부수입" 이라 적어 뒀다) 채널로 등록하는 코드가 없었다. 숙박은 자체
  홈페이지 보유율이 3업종 중 가장 높고(표본 25건 중 19건), 네이버 플레이스가 fact 를 3건밖에
  주지 않는 업소에서는 **그게 유일한 공개 출처**다. discover_official_site 로 등록·확정한다.
  - 추측이 아니다. 네이버가 그 업소 레코드에 달아 둔 값이고 동일 업소 판정(pick_match)을
    통과했을 때만 쓴다 — discover_naver_place 와 같은 근거라 자동 확정한다
  - 수집 금지 호스트(인스타·OTA)도 **등록은 한다**. 크롤은 static_html 의 _DENY_HOSTS 와
    robots 가 막지만, 공식 채널·sameAs 로는 유효한 사실이다
  - ★ 검색어에 `naver.region_key()` 를 쓰면 안 된다 — 그건 지명이 아니라 행정구역
    코드('52군산시')라 후보가 0건이 된다(실측). `naver_place_lookup.region_hint` 로 쓴다
  - collect_service 에 남아 있던 옛 표 이름(place_links.DBType) 한 곳도 같이 고쳤다 —
    스키마 재편 때 놓친 자리이고, 실행되는 순간에만 NameError 로 터진다

★ 숙박 예약 분기 복구 — `booking: isLodging ? StayBookingSection : BookingSection`
  64ce467 에서 사라져 숙박 발행이 절대규칙 3 대조에 걸려 통째로 막혀 있었다.

검증: site tsc·eslint·vitest 51 passed. 백엔드 pytest 529 passed / 52 failed —
**52건은 이 변경 전 main 에서도 같은 수로 실패한다**(기준선 확인). 스테이,머뭄 실발행으로
공식 채널·예약 채널 노출과 18섹션 확인.
This commit is contained in:
hbyang 2026-09-10 13:48:44 +09:00
parent e0d45eda97
commit ee51d89e83
4 changed files with 87 additions and 6 deletions

View File

@ -3,7 +3,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import area_contents
from common.enums import ErrorType, LocalContentType
from common.enums import ErrorType, LocalContentType, STORY_KINDS
from common.utils.gtime import GTime
@ -76,12 +76,22 @@ class LocalContentCRUD:
return await DB_SESSION_MNG.add(db, stmt)
async def list_kinds(self, db, region_code: str):
"""지역의 이야기 행 전부(종류당 1행). cache-aside 판단에 쓴다."""
"""지역의 **이야기** 행(종류당 1행). cache-aside 판단에 쓴다.
★ `kind IS NOT NULL` 로 고르면 안 된다. kind 는 이야기 전용 칸이 아니다 —
마이그레이션 0008 이 날씨·축제·명소·맛집에도 kind 를 채웠기 때문에(AREA_KIND),
그렇게 고르면 **이야기가 한 건도 없는 지역이 "이미 있다"로 판정된다.**
실측(2026-09-10, 전북 군산시): 주변정보 116건이 들어온 뒤로 `has_stories` 가 늘 참이라
지역 이야기 생성이 영영 건너뛰어졌고, 발행본에서 가요다방·인물열전·시간의 골목·
엽서·퀴즈 다섯 섹션이 통째로 비었다. 잡은 성공으로 끝나고 로그도 조용해서
"생성기가 없는 것" 처럼 보였다.
★ 그래서 STORY_KINDS 를 명시한다. 종류가 늘면 그 상수만 늘린다.
"""
return await DB_SESSION_MNG.execute(
db,
select(area_contents).where(
area_contents.region_code == region_code,
area_contents.kind.isnot(None),
area_contents.kind.in_(STORY_KINDS),
area_contents.deleted == False, # noqa: E712
),
)

View File

@ -89,6 +89,60 @@ async def discover_naver_place(place, place_id: str) -> str:
return "resolved" if added else "already"
async def discover_official_site(place, place_id: str) -> str:
"""네이버 **지역검색**이 주는 업체 자체 홈페이지를 채널로 등록한다.
반환 규약은 `discover_naver_place` 와 같다("resolved" / "already" / "not_found").
★ 왜 이게 필요한가 (실측 2026-09-10, 스테이,머뭄)
네이버 플레이스는 숙박에서 **fact 3건**(주차·와이파이·휠체어)밖에 주지 않는다.
체크인·체크아웃·취소규정·객실은 한 건도 없다. TourAPI 는 미등록 업소면 0건이고,
네이버 예약 페이지와 인스타그램은 robots 가 자동 수집을 금지한다. 그러면 남는
공개 출처는 **업소 자체 홈페이지 하나뿐**인데, 그 주소를 우리는 이미 받고 있었다 —
지역검색 응답의 `link` 다. 그걸 아무도 등록하지 않아 버려지고 있었다
(`external/naver.py` 머리주석: "채널 URL 발견에 쓸 수 있는 부수입이다").
숙박은 자체 홈페이지 보유율이 3업종 중 가장 높다(표본 25건 중 19건).
★ 지어내지 않는다. 검색으로 URL 을 **추측**하는 Perplexity 경로와 다르다 —
이건 네이버가 그 업소 레코드에 달아 둔 값이고, 동일 업소 판정(`pick_match`)을
통과했을 때만 쓴다. 그래서 `discover_naver_place` 와 같은 근거로 자동 확정한다.
★ 수집 금지 호스트도 **등록은 한다.** 인스타그램·OTA 는 robots 가 크롤을 막지만
(`static_html_adapter._DENY_HOSTS`), 발행본의 공식 채널·sameAs 로는 유효한 사실이다.
크롤 대상이 되어도 어댑터가 손들면 그 링크만 건너뛴다.
"""
from services.external import naver as naver_client
client = naver_client.NaverLocalClient()
if not client.enabled:
return "not_found"
address = place.road_address or place.address or ""
# ★ `naver.region_key()` 를 쓰면 안 된다 — 그건 지명이 아니라 **행정구역 코드**('52군산시')다.
# 검색어에 넣으면 후보가 0건이 된다(실측 2026-09-10). 사람이 읽는 지역 토막을 쓴다.
query = " ".join(x for x in (place.name, naver_place_lookup.region_hint(address)) if x)
try:
candidates = await client.search_local(query)
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다
LOG.w(f"[collect] 지역검색 실패(계속) {query!r}: {type(ex).__name__}: {ex}")
return "not_found"
match = naver_client.pick_match(place.name, candidates, address)
if not match.is_matched or not match.place or not match.place.place_url:
LOG.i(f"[collect] 자체 홈페이지 없음 — 네이버 레코드에 link 가 없거나 동일 업소 판정 실패 (place={place_id})")
return "not_found"
url = match.place.place_url
channel = LinkChannel.INSTAGRAM if "instagram.com" in url.lower() else LinkChannel.OFFICIAL_SITE
added = await _add_link(place_id, channel, url, f"{place.name} 공식 채널", SourceType.API, raw={"source": "naver_local"})
await DB_SESSION_MNG.execute_lambda_claim(
place_channels.DBType(),
lambda s: _place_crud.confirm_link_by_url(s, uuid.UUID(place_id), url, place.verified_by, GTime.UTC()),
)
LOG.i(f"[collect] 자체 홈페이지 {'등록·확정' if added else '확정'} — {url}")
return "resolved" if added else "already"
async def discover_tour_api(place, place_id: str) -> str:
"""검증된 상호·좌표로 TourAPI 콘텐츠를 찾아 등록한다. 반환값은 discover_naver_place 와 같은 규약이다.
@ -166,6 +220,16 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
LOG.w(f"[collect] TourAPI 조회 실패(계속): {type(ex).__name__}: {ex}")
stat["tour_api"] = "error"
# 자체 홈페이지 — 네이버 지역검색이 이미 준 값이라 추가 요금이 없다(위 함수 머리주석).
# ★ 숙박에서 이게 체크인·취소규정·객실을 가진 유일한 공개 출처인 경우가 많다.
try:
stat["official_site"] = await discover_official_site(place, place_id)
if stat["official_site"] == "resolved":
stat["discovered"] += 1
except Exception as ex: # noqa: BLE001
LOG.w(f"[collect] 자체 홈페이지 조회 실패(계속): {type(ex).__name__}: {ex}")
stat["official_site"] = "error"
# 오직 요청 옵션으로만 연다. 서버 env 로 일괄 활성화하면 일반 크롤링·재수집에서도
# 사용자가 모르는 유료 검색이 반복될 수 있으므로 COLLECT_USE_PERPLEXITY 는 더 쓰지 않는다.
if not include_perplexity:

View File

@ -52,7 +52,7 @@ def _normalize(text: str) -> str:
return re.sub(r"[\s,·.\-_'\"()&]", "", (text or "")).lower()
def _region_hint(address: Optional[str]) -> str:
def region_hint(address: Optional[str]) -> str:
"""주소에서 검색을 좁힐 지역 토막. 시/군/구까지만 쓴다.
★ 첫 토막만 쓰면 안 된다 — 그건 광역시·도('경기도')라 오히려 넓어진다.
@ -128,7 +128,7 @@ async def find_place_id(name: str, address: Optional[str] = None) -> Optional[st
★ 이름이 일치하는 후보만 받는다. '비슷한 것 중 첫 번째'를 고르면 남의 가게를
이 가게의 공식 채널로 등록하게 된다 — 이 제품에서 가장 비싼 실수다.
"""
query = " ".join(x for x in (name, _region_hint(address)) if x)
query = " ".join(x for x in (name, region_hint(address)) if x)
html = await _fetch_search_html(query)
if not html:
return None

View File

@ -59,7 +59,14 @@ export function HomePage() {
rooms: UnitsSection,
menu: UnitsSection,
programs: UnitsSection,
booking: BookingSection,
// ★ 숙박은 예약 섹션이 다른 컴포넌트다(요금·인원·창구 + 날짜/시간 목업).
// 같은 섹션 id 가 업종에 따라 다른 것을 그리는 자리는 여기가 유일하다 —
// 이유는 StayBookingSection 머리주석.
// ★ 이 줄이 64ce467 에서 조용히 사라졌다. 그러자 숙박 사이트의 예약 섹션이 전화번호
// 한 줄짜리 BookingSection 으로 돌아갔고, 화면에서 요금이 사라져 **발행이 통째로
// 막혔다** — JSON-LD 의 makesOffer.price 를 화면이 뒷받침하지 못해 절대규칙 3 대조에
// 걸린다(실측 2026-09-10: 발행본 2개 중 2개 실패).
booking: isLodging ? StayBookingSection : BookingSection,
space: SpaceSection,
inquiry: InquirySection,
exhibition: ExhibitionSection,