From ee51d89e837244f1c72decd63e7af8dc68e7acc4 Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 10 Sep 2026 13:48:44 +0900 Subject: [PATCH] =?UTF-8?q?[fix]=20solution/backend,site:=20=EC=A7=80?= =?UTF-8?q?=EC=97=AD=20=EC=9D=B4=EC=95=BC=EA=B8=B0=EA=B0=80=20=EC=98=81?= =?UTF-8?q?=EC=98=81=20=EC=95=88=20=EC=83=9D=EA=B8=B0=EB=8D=98=20=EA=B2=83?= =?UTF-8?q?=20+=20=EC=9E=90=EC=B2=B4=20=ED=99=88=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=9E=90=EB=8F=99=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스테이,머뭄으로 실제 발행해 시안(/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섹션 확인. --- solution/backend/crud/local_content_crud.py | 16 ++++- solution/backend/services/collect_service.py | 64 +++++++++++++++++++ .../services/external/naver_place_lookup.py | 4 +- solution/site/src/pages/HomePage.tsx | 9 ++- 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/solution/backend/crud/local_content_crud.py b/solution/backend/crud/local_content_crud.py index 4c0a717..19c4ac4 100644 --- a/solution/backend/crud/local_content_crud.py +++ b/solution/backend/crud/local_content_crud.py @@ -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 ), ) diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index 9fef1a3..c5c1950 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -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: diff --git a/solution/backend/services/external/naver_place_lookup.py b/solution/backend/services/external/naver_place_lookup.py index 1efe49a..4ecde97 100644 --- a/solution/backend/services/external/naver_place_lookup.py +++ b/solution/backend/services/external/naver_place_lookup.py @@ -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 diff --git a/solution/site/src/pages/HomePage.tsx b/solution/site/src/pages/HomePage.tsx index 4309529..df4b31c 100644 --- a/solution/site/src/pages/HomePage.tsx +++ b/solution/site/src/pages/HomePage.tsx @@ -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,