Compare commits

...

6 Commits

Author SHA1 Message Date
dd3ad86715 Merge branch 'main' into feature/crawler 2026-09-14 19:45:25 +09:00
a093921b62 feat(backend,site): NOL 수집 및 숙박 안내 구조화와 지역 콘텐츠 연동
수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다.

- NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가
- 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호
- 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시
- 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가
- 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선

검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
2026-09-14 19:40:04 +09:00
67a1db3cb9 feat(frontend): 캔버스 소개 요약값 배선(placeAdapter, common)
factField()가 FactData.summary를 InfoField.summary로 옮기고,
common.ts에 introSummary()를 추가한다. 아직 어느 변형도 이 값을
쓰지 않는다 — IntroSideBySide/IntroStory 연결은 다음 커밋.
2026-09-14 17:12:30 +09:00
afd41b425a feat(shared,frontend): InfoField/FactData에 요약 필드 추가
전체 orval 재생성은 이 브랜치의 다른 미완료 백엔드 작업(야놀자·맛집·
지역가이드 등)까지 끌어와 무관한 대규모 diff가 생겨서, summary 필드에
필요한 최소 변경만 손으로 반영했다(orval이 만들 결과와 동일한 모양).
2026-09-14 17:11:24 +09:00
8920e9b9d0 feat(backend): fact 목록 응답에 intro/room_intro AI 요약 부착
/v1/place/{id}/fact/list 가 intro·room_intro fact 중 200자를 넘는 것만
Gemini 로 요약해 FactData.summary 에 실어 보낸다. 짧은 값은 API를
부르지 않고, DB에도 저장하지 않는다(응답 전용).
2026-09-14 17:03:40 +09:00
3342981bd2 feat(backend): Gemini 기반 소개문 요약 함수 추가
캔버스 미리보기에서 intro/room_intro 원문이 길 때 CSS로 자르는 대신
실제 문장을 줄여 보여주기 위한 첫 단계. DB에는 저장하지 않고 프로세스
메모리 캐시(sha256 키)로 같은 원문의 재호출만 막는다.
2026-09-14 17:01:00 +09:00
63 changed files with 2524 additions and 92 deletions

View File

@ -16,6 +16,11 @@ WORKDIR /app
# 의존성 먼저 설치 (레이어 캐시 활용)
COPY solution/backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ★ yanolja_adapter.py 가 Playwright 로 페이지를 렌더링한다 — 패키지(pip)만으로는
# 브라우저 실행 파일이 없다. --with-deps 가 Chromium 이 필요로 하는 시스템 라이브러리
# (libnss3 등)까지 apt 로 같이 깐다. 세 프로세스(웹·admin·워커)가 이 이미지 하나를
# 공유하므로 실제로 fetch() 를 호출하는 건 워커뿐이어도 여기서 한 번만 설치한다.
RUN playwright install --with-deps chromium
COPY solution/backend ./solution/backend
COPY admin/backend ./admin/backend

View File

@ -343,6 +343,7 @@ class LocalSource(CodeEnum):
# ★ 지역 이야기 생성분. 출처는 항목 안의 source.url 이고 이 값은 '누가 모았나'다 —
# 화면이 "AI 가 모았습니다"를 밝힐 근거이자, 나중에 통째로 다시 돌릴 때의 선택자다.
LLM = 5
NAVER_CRAWL = 6 # 네이버 플레이스 크롤링(주변 맛집 보강). docs/DECISIONS.md 1-1 예외 — 봇탐지 우회 없이 공개 응답만 읽는다
class LocalContentStatus(CodeEnum):

View File

@ -31,6 +31,7 @@ class PlaceContentCRUD:
select(
area_contents.local_content_id,
area_contents.content_type,
area_contents.source,
area_contents.external_id,
area_contents.title,
area_contents.body,

View File

@ -12,3 +12,4 @@ httpx
apscheduler>=3.10
pydantic-settings # 환경변수·.env 로드 (FastAPI 공식 설정 방식)
azure-storage-blob>=12.19
playwright # services/collector/yanolja_adapter.py 가 요구 (registry.py import 시점에 필요)

View File

@ -75,6 +75,9 @@ class FactData(WebPacketProtocol):
unit_id: Optional[uuid.UUID] = None
key: str
value: Optional[str] = None
# ★ 캔버스 미리보기용 축약문. intro/room_intro 원문이 길 때만 채운다 — DB 에는 없다(응답 전용,
# FactService._attach_summaries 가 요청마다 계산해 붙인다).
summary: Optional[str] = None
unit: Optional[str] = None
source_type: SourceType
source_url: Optional[str] = None

View File

@ -0,0 +1,73 @@
"""기존 크롤링 후보를 현재 기록 정책으로 다시 적용한다. --apply 없이는 조회만 한다."""
import argparse
import asyncio
import json
import sys
import uuid
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_facts, places
from common.enums import DBWRType, ErrorType, FactStatus, SourceType
from common.models.gmodel import UserInfo
from crud.fact_crud import FactCRUD
from crud.place_crud import PlaceCRUD
from router.v1.fact.protocol import Req_UpsertFact
from services.fact_service import FactService
async def main(place_id, apply):
try:
err, rows = await DB_SESSION_MNG.execute_lambda(
places.DBType(), DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(s, select(places).where(
places.place_id == place_id, places.deleted.is_(False))),
)
if err != ErrorType.SUCCESS or len(rows or []) != 1:
raise RuntimeError('사업장 조회 실패')
place = rows[0]
err, facts = await DB_SESSION_MNG.execute_lambda(
place_facts.DBType(), DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(s, select(place_facts).where(
place_facts.place_id == place_id, place_facts.deleted.is_(False),
place_facts.source_type == SourceType.CRAWL.value,
place_facts.status.in_([FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value]),
).order_by(place_facts.collected_at.desc())),
)
if err != ErrorType.SUCCESS:
raise RuntimeError('크롤링 후보 조회 실패')
service = FactService(FactCRUD(), PlaceCRUD())
actor = UserInfo(user_id=str(place.owner_user_id), id='crawl-policy', role=1)
seen = set()
result = []
for fact in facts or []:
key = (fact.unit_id, fact.key)
if key in seen:
continue
seen.add(key)
row = {'unitId': str(fact.unit_id), 'key': fact.key, 'value': fact.value}
if apply:
res = await service.upsert_fact(actor, str(place_id), Req_UpsertFact(
key=fact.key, value=fact.value, unit_id=fact.unit_id,
source_type=SourceType.CRAWL, source_url=fact.source_url,
expires_at=fact.expires_at,
))
if not res.result.success:
raise RuntimeError(f'{fact.key}: {res.result.desc}')
row['status'] = res.fact.status
result.append(row)
print(json.dumps({'applied': apply, 'place': place.name, 'facts': result},
ensure_ascii=False, default=str))
finally:
await DB_SESSION_MNG.dispose_all()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--place-id', required=True, type=uuid.UUID)
parser.add_argument('--apply', action='store_true')
args = parser.parse_args()
asyncio.run(main(args.place_id, args.apply))

View File

@ -0,0 +1,42 @@
"""숙박 공통 감성 문구를 한 번 생성한다. 방문 시에는 저장된 문구만 순환한다."""
import asyncio
import json
import sys
from pathlib import Path
import httpx
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from services.llm.gemini import DEFAULT_MODEL, call, extract_text
PROMPT = """여러 숙박업체 홈페이지 첫 화면에서 돌아가며 보여 줄 한국어 감성 문구 10개를 작성하세요.
호텔, 모텔, 펜션, 게스트하우스 어디에나 있어야 합니다.
휴식, 여행, 머무는 시간, 일상에서의 잠깐의 쉼을 담아 담백하고 따뜻하게 쓰세요.
문구는 공백 포함 15~30. 과장, 진부한 최상급, 상호명, 지명, 계절, 날씨,
바다//정원/독채 시설이나 경관, 청결/조식/가격 확인이 필요한 사실과 서비스 약속은 금지합니다.
서로 다른 표현으로 정확히 10, 문자열 JSON 배열만 반환하세요."""
async def main():
async with httpx.AsyncClient(timeout=90) as client:
response = await call(client, DEFAULT_MODEL, {
"contents": [{"role": "user", "parts": [{"text": PROMPT}]}],
"generationConfig": {
"responseMimeType": "application/json",
"responseSchema": {"type": "ARRAY", "items": {"type": "STRING"}, "minItems": 10, "maxItems": 10},
"temperature": 0.8,
},
}, max_retries=0)
lines = json.loads(extract_text(response))
if not isinstance(lines, list) or len(lines) != 10 or not all(isinstance(s, str) and 15 <= len(s.strip()) <= 30 for s in lines):
raise ValueError("문구 개수·길이 검증 실패")
lines = [s.strip() for s in lines]
if len(set(lines)) != 10:
raise ValueError("중복 문구")
target = Path(__file__).resolve().parents[2] / "site/src/lib/lodging-catchphrases.json"
target.write_text(json.dumps(lines, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"model": DEFAULT_MODEL, "items": lines}, ensure_ascii=False))
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,186 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
야놀자 크롤링 결과를 테스트 place 하나에 적재하는 1회성 스크립트.
=================================================================
운영 파이프라인이 아니다 services/collector/registry.py 등록하지 않고,
worker 잡큐에도 연결하지 않는다. 개발자가 수동으로 실행하는 CLI 전용.
(registry.py·static_html_adapter.py 야놀자 관련 구조적 차단은 그대로 유효하다
스크립트는 결론을 뒤집지 않는다. 테스트 목적 한정, 실제 배포 별도 협의 필요.)
동작
1. place_id places 테이블에서 업장을 조회한다. 주소는 따로 입력받지 않고
업장의 road_address(없으면 address) 그대로 검색어로 쓴다.
2. verified_at 비어 있으면 중단한다(운영 run_collect 같은 가드
동일 업소 검증 전에는 수집하지 않는다).
3. scripts/yanolja_search_and_crawl.py 주소를 검색·크롤링한다.
4. 결과를 CollectedFact/CollectedMedia 매핑해서, 기존 collect_service.py
ensure_units/store_facts/store_media 그대로 호출한다 FactService 그대로
통과하므로 값은 UNVERIFIED/PENDING_OWNER/PENDING_REVIEW 로만 만들어진다.
상태를 스크립트가 직접 VERIFIED/APPROVED 바꾸는 일은 없다.
사용법 (solution/backend 에서, 가상환경 안에서)
python scripts/import_yanolja_test_place.py --place-id <UUID> (dry-run: 매핑만 출력)
python scripts/import_yanolja_test_place.py --place-id <UUID> --commit (실제 DB 적재)
PGSSLMODE=disable DB_PASSWORD=... dev-env-quirks 메모대로 주입해야 한다(APP_ENV=local 기본).
"""
from __future__ import annotations
import argparse
import asyncio
import os
import re
import sys
import uuid
# scripts/ 는 solution/backend 바로 아래이므로, backend 자체(부모 디렉터리)를
# sys.path 에 넣어야 common/services 등을 import 할 수 있다.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("APP_ENV", "local")
from playwright.sync_api import sync_playwright # noqa: E402
from sqlalchemy import select # noqa: E402
from scripts.yanolja_search_and_crawl import StayData, search_and_crawl # noqa: E402
from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402
from common.database.model.models import places # noqa: E402
from common.enums import DBWRType, ErrorType # noqa: E402
from services.collector.base import CollectedFact, CollectedMedia, RawSource # noqa: E402
from services.collect_service import ensure_units, store_facts, store_media # noqa: E402
def _parse_capacity(capacity: str | None) -> tuple[str | None, str | None]:
"""'기준 2인 / 최대 4인' → ('2', '4')."""
if not capacity:
return None, None
std_m = re.search(r"기준\s*(\d+)인", capacity)
max_m = re.search(r"최대\s*(\d+)인", capacity)
return (std_m.group(1) if std_m else None, max_m.group(1) if max_m else None)
def stay_data_to_source(data: StayData) -> RawSource:
"""StayData → RawSource(facts/media). lodging.json 의 scope=unit 필드 중
크롤러가 실제로 채울 있는 것만 매핑한다(room_type/standard_capacity/max_capacity
required=true 반드시 채운다. weekday_price 요금은 크롤러가 모으므로 비워둔다).
"""
facts: list[CollectedFact] = []
media: list[CollectedMedia] = []
if data.overview.strip():
facts.append(CollectedFact(key="intro", value=data.overview.strip(), scope="place"))
for url in data.photos:
media.append(CollectedMedia(origin_url=url, label="갤러리"))
for room in data.rooms:
std_cap, max_cap = _parse_capacity(room.capacity)
facts.append(CollectedFact(key="room_type", value=room.name, scope="unit", unit_name=room.name))
if std_cap:
facts.append(CollectedFact(key="standard_capacity", value=std_cap, scope="unit", unit_name=room.name))
if max_cap:
facts.append(CollectedFact(key="max_capacity", value=max_cap, scope="unit", unit_name=room.name))
if room.bed:
facts.append(CollectedFact(key="bed_type", value=room.bed, scope="unit", unit_name=room.name))
if room.description:
facts.append(CollectedFact(key="room_intro", value=room.description, scope="unit", unit_name=room.name))
for url in room.images:
media.append(CollectedMedia(origin_url=url, label="객실 사진", unit_name=room.name))
return RawSource(url=data.url, adapter_id="yanolja_test_import", facts=facts, media=media)
def _print_preview(source: RawSource) -> None:
print(f"\n[미리보기] {source.url}")
place_facts = [f for f in source.facts if f.scope == "place"]
print(f" place-scope fact {len(place_facts)}건: {[f.key for f in place_facts]}")
for unit_name in source.unit_names():
unit_facts = [f for f in source.facts if f.unit_name == unit_name]
unit_media = [m for m in source.media if m.unit_name == unit_name]
print(f" [{unit_name}] fact {len(unit_facts)}건, 사진 {len(unit_media)}")
for f in unit_facts:
print(f" {f.key} = {f.value}")
place_media = [m for m in source.media if m.unit_name is None]
print(f" place-scope 사진 {len(place_media)}")
async def _load_place(place_id: str):
stmt = select(places).where(places.place_id == uuid.UUID(place_id), places.deleted == False) # noqa: E712
err, row = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(s, stmt),
)
if err != ErrorType.SUCCESS or not row:
return None
return row[0]
def _crawl_sync(address: str, headful: bool) -> StayData:
"""동기 Playwright API는 asyncio 루프가 도는 스레드에서 못 쓴다 — 별도 스레드에서 실행한다."""
with sync_playwright() as p:
browser = p.chromium.launch(headless=not headful)
context = browser.new_context(
locale="ko-KR",
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
),
)
page = context.new_page()
try:
return search_and_crawl(page, address)
finally:
browser.close()
async def run(place_id: str, commit: bool, headful: bool) -> None:
place = await _load_place(place_id)
if place is None:
raise SystemExit(f"place를 찾을 수 없습니다: {place_id}")
if place.verified_at is None:
raise SystemExit(
"동일 업소 검증(verify) 전에는 수집하지 않습니다 — 운영 collect_service.run_collect 와 같은 가드."
)
address = place.road_address or place.address
if not address:
raise SystemExit("이 place에는 주소가 없습니다.")
print(f"[대상] {place.name} ({place_id}) — 검색 주소: {address}")
data = await asyncio.to_thread(_crawl_sync, address, headful)
print(f"[크롤링 완료] {data.name} — 객실 {len(data.rooms)}개, 갤러리 사진 {len(data.photos)}")
source = stay_data_to_source(data)
_print_preview(source)
if not commit:
print("\n(dry-run) DB에는 쓰지 않았습니다. 실제로 저장하려면 --commit 을 주세요.")
await DB_SESSION_MNG.dispose_all()
return
from common.models.gmodel import UserInfo
actor = UserInfo(user_id=str(place.owner_user_id), id="yanolja_test_import", role=1)
unit_map = await ensure_units(place_id, [source])
facts_stat = await store_facts(actor, place_id, [source], unit_map)
media_stat = await store_media(place_id, [source], unit_map)
print(f"\n[적재 완료] unit {len(unit_map)}개, fact={facts_stat}, media={media_stat}")
await DB_SESSION_MNG.dispose_all()
def main() -> None:
parser = argparse.ArgumentParser(description="야놀자 크롤링 → 테스트 place 객실 데이터 임포트(1회성)")
parser.add_argument("--place-id", required=True, help="대상 place_id(UUID)")
parser.add_argument("--commit", action="store_true", help="실제로 DB에 적재(기본은 dry-run)")
parser.add_argument("--headful", action="store_true", help="브라우저 창을 보이게 실행(디버깅용)")
args = parser.parse_args()
asyncio.run(run(args.place_id, args.commit, args.headful))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,94 @@
"""군산 공통 맛집 한일옥 등록. 기본은 조회, --apply로 현재 설정 DB에 반영한다.
external_id가 없는 지역 공통 항목은 snapshot이 모든 군산 업장에 포함한다.
네이버 ID는 body에 보관하여 자동 수집의 (source, external_id) 갱신과 분리한다.
"""
import argparse
import asyncio
import json
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.engine import URL
from sqlalchemy.ext.asyncio import create_async_engine
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import area_contents, places
from common.enums import LocalContentStatus, LocalContentType, LocalSource
from config.server_configs import main_db_config as cfg
from services.site_payload import _local
from services.snapshot import _local_contents
REGION = '52군산시'
NAVER_ID = '11861452'
CONTENT_ID = uuid.uuid5(uuid.NAMESPACE_URL, 'web4ai:52군산시:restaurant:11861452')
async def main(apply: bool):
engine = create_async_engine(URL.create(
'postgresql+asyncpg', username=cfg.write_id, password=cfg.write_pw,
host=cfg.write_host, port=cfg.write_port, database=cfg.name,
))
try:
async with engine.begin() as conn:
rows = (await conn.execute(select(
area_contents.local_content_id, area_contents.title, area_contents.body,
).where(
area_contents.region_code == REGION,
area_contents.kind == 'restaurant', area_contents.external_id.is_(None),
area_contents.deleted.is_(False),
))).mappings().all()
if rows and any(r['body'].get('naverPlaceId') != NAVER_ID for r in rows):
raise RuntimeError('다른 군산 공통 맛집이 이미 있어 덮어쓰지 않습니다.')
content_id = rows[0]['local_content_id'] if rows else CONTENT_ID
now = datetime.now(timezone.utc)
values = dict(
local_content_id=content_id, region_code=REGION,
content_type=LocalContentType.RESTAURANT.value, kind='restaurant',
# 사용자 확인을 거친 수기 웹 등록. 크롤링한 값으로 표시하지 않는다.
source=LocalSource.OFFICIAL_WEB.value, external_id=None, title='한일옥',
body={**(rows[0]['body'] if rows else {}),
'name': '한일옥', 'searchQuery': '군산 한일옥',
'naverPlaceId': NAVER_ID,
'sourceUrl': f'https://m.place.naver.com/restaurant/{NAVER_ID}/home',
'registration': 'owner_confirmed_region_default'},
status=LocalContentStatus.PUBLISHED.value, published_at=now,
collected_at=now, display_start_at=None, display_end_at=None,
expires_at=None, deleted=False, updated_at=now,
)
if apply:
await conn.execute(insert(area_contents).values(**values).on_conflict_do_update(
index_elements=[area_contents.local_content_id],
set_={k: v for k, v in values.items() if k != 'local_content_id'},
))
targets = (await conn.execute(select(places.place_id, places.name).where(
places.deleted.is_(False), places.region_code == REGION,
))).mappings().all()
print(json.dumps({'applied': apply, 'database': cfg.name, 'region': REGION,
'contentId': str(content_id), 'naverPlaceId': NAVER_ID,
'existingPlaces': [dict(r) for r in targets]},
default=str, ensure_ascii=False))
if apply:
# place_id 없는 새 군산 업장도 지역 공통 경로만으로 받는지 확인한다.
snapshot = await _local_contents(SimpleNamespace(region_code=REGION))
local, _ = _local(snapshot, None, None)
matches = [r for r in local['restaurants'] if r['name'] == '한일옥']
assert len(matches) == 1, '지역 공통 payload에 한일옥이 정확히 한 번 있어야 합니다.'
print(json.dumps({'verifiedRegionalPayload': matches}, ensure_ascii=False))
finally:
await engine.dispose()
await DB_SESSION_MNG.dispose_all()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--apply', action='store_true')
asyncio.run(main(parser.parse_args().apply))

View File

@ -0,0 +1,142 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
테스트 place에 야놀자 크롤링으로 넣은 객실 fact/사진을 **수동으로 검수·승인**해서,
실제 미리보기 렌더러(site_payload.to_site_payload UnitsSection) 뜨는지 확인하는
1회성 스크립트.
이것도 운영 파이프라인이 아니다 사람이 눈으로 확인하려고 이번 테스트 place 하나에만
쓰는 수동 검수 도구다. 실제 서비스에서 크롤링 값을 이렇게 자동 승인하면 된다
(fact는 사람이 [맞아요] 눌러야, 사진은 Vision/사람이 봐야 승인 상태가 된다).
사용법 (solution/backend 에서, 가상환경 ):
PGSSLMODE=disable DB_PASSWORD=... python scripts/verify_yanolja_test_place.py --place-id <UUID>
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
import uuid
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("APP_ENV", "local")
from sqlalchemy import select # noqa: E402
from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402
from common.database.model.models import places, place_facts, place_photos # noqa: E402
from common.enums import DBWRType, ErrorType, FactStatus, MediaStatus # noqa: E402
from common.models.gmodel import UserInfo # noqa: E402
from common.utils.gtime import GTime # noqa: E402
from crud.fact_crud import FactCRUD # noqa: E402
from crud.media_crud import MediaCRUD # noqa: E402
from crud.place_crud import PlaceCRUD # noqa: E402
from crud.site_crud import SiteCRUD # noqa: E402
from crud.job_crud import JobQueue # noqa: E402
from router.v1.fact.protocol import Req_TransitionFact # noqa: E402
from services.fact_service import FactService # noqa: E402
from services.site_service import SiteService # noqa: E402
# 검수 전 상태(우리가 이번에 넣은 값들)만 건드린다.
_PENDING_FACT_STATUSES = (FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value)
async def _load_place(place_id: str):
stmt = select(places).where(places.place_id == uuid.UUID(place_id), places.deleted == False) # noqa: E712
err, row = await DB_SESSION_MNG.execute_lambda(
places.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, stmt)
)
if err != ErrorType.SUCCESS or not row:
return None
return row[0]
async def _unit_scope_facts(place_id: str):
stmt = select(place_facts).where(
place_facts.place_id == uuid.UUID(place_id),
place_facts.deleted == False, # noqa: E712
place_facts.unit_id.is_not(None),
place_facts.status.in_(_PENDING_FACT_STATUSES),
)
err, rows = await DB_SESSION_MNG.execute_lambda(
place_facts.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, stmt)
)
return rows if err == ErrorType.SUCCESS else []
async def _unit_scope_pending_photos(place_id: str):
stmt = select(place_photos).where(
place_photos.place_id == uuid.UUID(place_id),
place_photos.deleted == False, # noqa: E712
place_photos.unit_id.is_not(None),
place_photos.status == MediaStatus.PENDING_REVIEW.value,
)
err, rows = await DB_SESSION_MNG.execute_lambda(
place_photos.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, stmt)
)
return rows if err == ErrorType.SUCCESS else []
async def run(place_id: str) -> None:
place = await _load_place(place_id)
if place is None:
raise SystemExit(f"place를 찾을 수 없습니다: {place_id}")
actor = UserInfo(user_id=str(place.owner_user_id), id="yanolja_test_verify", role=1)
fact_service = FactService(FactCRUD(), PlaceCRUD())
media_crud = MediaCRUD()
facts = await _unit_scope_facts(place_id)
print(f"[검수 대상 fact] {len(facts)}")
verified = 0
for f in facts:
res = await fact_service.transition(
actor, place_id, str(f.fact_id), Req_TransitionFact(status=FactStatus.VERIFIED)
)
if res.result.success:
verified += 1
else:
print(f" ! {f.key}({f.fact_id}) 전이 실패: {res.result.desc}")
print(f" -> VERIFIED 처리 {verified}/{len(facts)}")
photos = await _unit_scope_pending_photos(place_id)
print(f"[승인 대상 사진] {len(photos)}")
approved = 0
now = GTime.UTC()
for p in photos:
alt_text = p.label or "객실 사진"
run_err, _rowcount = await DB_SESSION_MNG.execute_lambda_claim(
place_photos.DBType(),
lambda s, m=p.media_id, a=alt_text: media_crud.apply_vision(
s, m, p.label, a, None, MediaStatus.APPROVED.value, now
),
)
if run_err == ErrorType.SUCCESS:
approved += 1
print(f" -> APPROVED 처리 {approved}/{len(photos)}건 (alt_text 채움)")
site_service = SiteService(SiteCRUD(), PlaceCRUD(), JobQueue())
payload = await site_service.preview_payload(actor, place_id)
units = (payload or {}).get("units", [])
print(f"\n[/preview 재확인] units {len(units)}")
for u in units:
print(f" - {u.get('name')}: fact {len(u.get('facts', []))}개, media {len(u.get('mediaIds', []))}")
if not units:
print(" (여전히 비어 있습니다 — payload 구조나 상태값을 다시 확인하세요)")
print(json.dumps(payload, ensure_ascii=False, default=str)[:2000])
await DB_SESSION_MNG.dispose_all()
def main() -> None:
parser = argparse.ArgumentParser(description="테스트 place의 야놀자 객실 데이터를 검수·승인해서 미리보기에 반영")
parser.add_argument("--place-id", required=True)
args = parser.parse_args()
asyncio.run(run(args.place_id))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,397 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
야놀자(NOL) 주소 검색 + 숙소 상세페이지 크롤러
==========================================
운영 collector 파이프라인(services/collector/registry.py) 등록되지 않은 수동 도구다.
registry.py·static_html_adapter.py 야놀자를 구조적으로 막아둔 이유(HTTP 403 회피
목적의 탐지 우회 금지, 재게시 관련 민사 판례) 그대로 유효하다. 스크립트는
개발자가 필요할 수동으로만 실행하는 진단·백필용 도구이며, worker/collect_service
에서 자동으로 호출되지 않는다.
동작 순서
1. https://nol.yanolja.com/ 접속
2. 검색창에 주소를 입력하고 검색 실행
3. 검색결과 리스트에서 첫번째 업체(숙소) 상세페이지 href 읽어 바로 이동
4. 이동한 상세페이지에서 객실/숙소소개/시설·서비스/이용안내/예약공지 + 객실별 사진을 크롤링
사전 준비
pip install playwright
playwright install chromium
사용법 (solution/backend 에서)
python scripts/yanolja_search_and_crawl.py "전북특별자치도 군산시 절골길 18" -o result.json
python scripts/yanolja_search_and_crawl.py "전북특별자치도 군산시 절골길 18" --print
python scripts/yanolja_search_and_crawl.py "전북특별자치도 군산시 절골길 18" --headful
주의
- 검색 페이지, 상세페이지 모두 Next.js 기반 CSR 페이지라서 Playwright로
실제 브라우저를 띄워 렌더링을 끝낸 DOM에서 데이터를 추출한다.
- 페이지 구조(클래스명 ) 야놀자 쪽에서 언제든 바뀔 있다.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import asdict, dataclass, field
from typing import Optional
from playwright.sync_api import Page, TimeoutError as PWTimeoutError, sync_playwright
SEARCH_URL = "https://nol.yanolja.com/"
# 검색결과 카드는 <a data-card-type="basic" ... aria-label="{업체명} 상품 상세 보기" href="...">
RESULT_CARD_SELECTOR = 'a[data-card-type="basic"]'
# 각 탭이 스크롤/렌더링되는 실제 섹션 id (2026-09 기준 확인됨)
SECTION_IDS = {
"rooms": "PLACE_SECTION", # 객실선택
"overview": "OVERVIEW_SECTION", # 숙소소개
"service": "SERVICE_SECTION", # 시설/서비스
"policy": "POLICY_SECTION", # 이용안내
"reservation": "RESERVATION_SECTION", # 예약공지
}
@dataclass
class RoomInfo:
name: str
description: Optional[str] = None
capacity: Optional[str] = None # 예: "기준 2인 / 최대 4인"
bed: Optional[str] = None # 예: "킹 침대 1개"
raw_text: str = ""
images: list[str] = field(default_factory=list) # 객실 사진 URL(아이콘 제외)
@dataclass
class StayData:
url: str
stay_id: Optional[str] = None
name: Optional[str] = None
search_address: Optional[str] = None
picked_name: Optional[str] = None
rooms: list[RoomInfo] = field(default_factory=list)
overview: str = "" # 숙소 소개
service: str = "" # 시설/서비스
policy: str = "" # 이용 안내
reservation: str = "" # 예약 공지
photos: list[str] = field(default_factory=list) # 숙소소개 갤러리 사진 URL(있으면)
def search_and_pick_first(page: Page, address: str, timeout_ms: int = 15000) -> tuple[str, Optional[str]]:
"""주소로 검색한 뒤, 첫번째 검색결과 카드의 href를 추출해서 상세페이지로 이동한다.
카드를 클릭하지 않고 href를 직접 읽어 page.goto() 이동한다. 클릭
탭이 뜨거나 배너/모달에 클릭이 가로채이는 문제를 피하기 위함이다.
"""
page.goto(SEARCH_URL, wait_until="domcontentloaded")
search_box = page.get_by_role("combobox", name="검색어 입력")
search_box.click()
search_box.fill(address)
search_box.press("Enter")
try:
first_card = page.locator(RESULT_CARD_SELECTOR).first
first_card.wait_for(state="visible", timeout=timeout_ms)
except PWTimeoutError:
raise RuntimeError(f"'{address}' 검색결과를 찾지 못했습니다.")
name = first_card.get_attribute("aria-label") or first_card.inner_text()
detail_url = first_card.get_attribute("href")
if not detail_url:
raise RuntimeError("검색결과 카드에서 href를 찾지 못했습니다.")
page.goto(detail_url, wait_until="domcontentloaded")
return page.url, name # page.url: href가 상대경로여도 절대 URL로 해석된 값
def _extract_stay_id(url: str) -> Optional[str]:
m = re.search(r"/stay/domestic/(\d+)", url)
return m.group(1) if m else None
def _scroll_through_page(page: Page) -> None:
"""지연 로딩(lazy render) 섹션들이 모두 렌더링되도록 페이지를 끝까지 스크롤."""
prev_height = -1
for _ in range(30):
page.mouse.wheel(0, 1200)
page.wait_for_timeout(250)
cur_height = page.evaluate("document.body.scrollHeight")
if cur_height == prev_height:
break
prev_height = cur_height
page.evaluate("window.scrollTo(0, 0)")
def _get_section_text(page: Page, element_id: str) -> str:
try:
page.evaluate(
f"""() => {{
const el = document.getElementById({json.dumps(element_id)});
if (el) el.scrollIntoView({{block: 'center'}});
}}"""
)
page.wait_for_timeout(400)
return page.evaluate(
f"""() => {{
const el = document.getElementById({json.dumps(element_id)});
return el ? el.innerText : '';
}}"""
) or ""
except Exception:
return ""
# img src 중 실제 사진(/v5/.../*.jpg 형태)만 남기고 UI 아이콘(static/images/... 의 침대·와이파이
# 아이콘, no-image 플레이스홀더 등)은 제외한다.
def _is_real_photo_url(src: str) -> bool:
return bool(src) and "static/images" not in src
def _get_room_images_by_name(page: Page, element_id: str) -> list[tuple[str, list[str]]]:
"""PLACE_SECTION 안에서 각 객실 카드의 사진을, 그 카드의 <h2>(객실명) 앞에 나오는
<img> 들로 묶어 [(객실명, [사진 URL, ...]), ...] 순서대로 돌려준다.
카드 구조가 "사진 캐러셀 → <h2>객실명</h2> → 설명" 순이라, h2 만나기 전까지
쌓인 이미지가 h2 몫이다.
"""
try:
page.evaluate(
f"""() => {{
const el = document.getElementById({json.dumps(element_id)});
if (el) el.scrollIntoView({{block: 'center'}});
}}"""
)
page.wait_for_timeout(400)
raw = page.evaluate(
f"""() => {{
const el = document.getElementById({json.dumps(element_id)});
if (!el) return [];
const nodes = el.querySelectorAll('img, h2');
const result = [];
let buf = [];
for (const n of nodes) {{
if (n.tagName === 'IMG') {{
if (n.src) buf.push(n.src);
}} else if (n.tagName === 'H2') {{
result.push({{name: n.textContent.trim(), images: [...new Set(buf)]}});
buf = [];
}}
}}
return result;
}}"""
) or []
except Exception:
return []
return [
(item["name"], [u for u in item["images"] if _is_real_photo_url(u)])
for item in raw
]
def _get_section_images(page: Page, element_id: str) -> list[str]:
"""섹션 안의 사진 URL을 순서대로(중복 제거, 아이콘 제외)."""
try:
page.evaluate(
f"""() => {{
const el = document.getElementById({json.dumps(element_id)});
if (el) el.scrollIntoView({{block: 'center'}});
}}"""
)
page.wait_for_timeout(400)
srcs = page.evaluate(
f"""() => {{
const el = document.getElementById({json.dumps(element_id)});
if (!el) return [];
return [...new Set(Array.from(el.querySelectorAll('img')).map(i => i.src).filter(Boolean))];
}}"""
) or []
except Exception:
return []
return [u for u in srcs if _is_real_photo_url(u)]
def _get_stay_name(page: Page) -> Optional[str]:
try:
h1 = page.query_selector("h1")
return h1.inner_text().strip() if h1 else None
except Exception:
return None
def _parse_rooms_from_section_text(section_text: str) -> list[RoomInfo]:
"""
PLACE_SECTION.innerText 대략 아래 패턴이 객실 카드 수만큼 반복됩니다:
1
/
10
B동
(모던한 현대식 컨셉으로 꾸며진 따뜻한 공간)
기준 2 / 최대 4
침대 1
숙박
체크인
15:00
~ 체크아웃
11:00
취소 환불 불가
상세보기
198,000
NOL 머니 결제 최대 3,960P 적립
예약하기
우리가 필요한 이름/설명/기준·최대인원/침대 뿐이므로 가격 이하는 무시한다.
사이트 구조가 바뀌면 정규식도 함께 손봐야 한다.
"""
rooms: list[RoomInfo] = []
# "N / M" (사진 장수) 로 카드 시작 지점을 나눈다
chunks = re.split(r"\n?\d+\s*\n/\n\d+\n", section_text)
for chunk in chunks[1:]: # 첫 chunk는 "객실 선택" 타이틀 등 헤더
lines = [l.strip() for l in chunk.split("\n") if l.strip()]
if not lines:
continue
name = lines[0]
capacity_m = re.search(r"기준\s*\d+인\s*/\s*최대\s*\d+인", chunk)
bed_m = re.search(r"(킹|퀸|더블|싱글|트윈)\s*침대\s*\d+개", chunk)
# 이름과 "기준 N인" 줄 사이의 줄들을 설명으로 간주
desc_lines = []
for l in lines[1:]:
if l.startswith("기준") or "체크인" in l or l == "숙박":
break
desc_lines.append(l.strip("()"))
rooms.append(
RoomInfo(
name=name,
description=" ".join(desc_lines) if desc_lines else None,
capacity=capacity_m.group(0) if capacity_m else None,
bed=bed_m.group(0) if bed_m else None,
raw_text=chunk.strip(),
)
)
return rooms
def crawl_stay(page: Page, url: str) -> StayData:
page.goto(url, wait_until="domcontentloaded", timeout=60000)
try:
page.wait_for_selector("h1", timeout=15000)
except PWTimeoutError:
pass
_scroll_through_page(page)
data = StayData(url=url, stay_id=_extract_stay_id(url))
data.name = _get_stay_name(page)
rooms_text = _get_section_text(page, SECTION_IDS["rooms"])
data.rooms = _parse_rooms_from_section_text(rooms_text)
# 카드 순서대로 이미지를 묶어서 뽑은 뒤, 순서가 맞아떨어지면 그대로 붙이고
# (카드 수가 어긋나면 사이트 구조가 달라진 것이니) 이름이 같은 것끼리 다시 맞춘다.
room_images = _get_room_images_by_name(page, SECTION_IDS["rooms"])
if len(room_images) == len(data.rooms):
for room, (_, images) in zip(data.rooms, room_images):
room.images = images
else:
images_by_name = {name: images for name, images in room_images}
for room in data.rooms:
room.images = images_by_name.get(room.name, [])
data.overview = _get_section_text(page, SECTION_IDS["overview"])
data.photos = _get_section_images(page, SECTION_IDS["overview"])
data.service = _get_section_text(page, SECTION_IDS["service"])
data.policy = _get_section_text(page, SECTION_IDS["policy"])
data.reservation = _get_section_text(page, SECTION_IDS["reservation"])
return data
def search_and_crawl(page: Page, address: str) -> StayData:
detail_url, picked_name = search_and_pick_first(page, address)
data = crawl_stay(page, detail_url)
data.search_address = address
data.picked_name = picked_name
return data
def _print_stay(data: StayData) -> None:
print(f"\n===== {data.name} ({data.url}) =====")
if data.search_address:
print(f"검색 주소: {data.search_address}")
if data.picked_name:
print(f"검색결과 선택: {data.picked_name}")
print("\n[객실 선택]")
for r in data.rooms:
print(f" - {r.name}")
if r.description:
print(f" 설명: {r.description}")
if r.capacity:
print(f" 인원: {r.capacity}")
if r.bed:
print(f" 침대: {r.bed}")
if r.images:
print(f" 사진: {len(r.images)}장 (예: {r.images[0]})")
if data.photos:
print(f"\n갤러리 사진: {len(data.photos)}")
for label, key in [("숙소 소개", "overview"), ("시설/서비스", "service"),
("이용 안내", "policy"), ("예약 공지", "reservation")]:
print(f"\n[{label}]")
text = getattr(data, key)
print(text if text else "(내용 없음)")
def main() -> None:
parser = argparse.ArgumentParser(description="야놀자(NOL) 주소 검색 + 숙소 상세페이지 크롤러")
parser.add_argument(
"address",
nargs="?",
default="전북특별자치도 군산시 절골길 18",
help="검색할 주소 (예: '전북특별자치도 군산시 절골길 18')",
)
parser.add_argument("-o", "--output", default="yanolja_result.json", help="결과 저장 파일명(JSON)")
parser.add_argument("--print", action="store_true", dest="do_print", help="결과를 터미널에도 보기 좋게 출력")
parser.add_argument("--headful", action="store_true", help="브라우저 창을 보이게 실행(디버깅용)")
args = parser.parse_args()
print(f"[검색 시작] 주소: {args.address}", file=sys.stderr)
with sync_playwright() as p:
browser = p.chromium.launch(headless=not args.headful)
context = browser.new_context(
locale="ko-KR",
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
),
)
page = context.new_page()
try:
data = search_and_crawl(page, args.address)
except Exception as e:
browser.close()
print(f"실패: {e}", file=sys.stderr)
sys.exit(1)
browser.close()
print(f" -> 완료: {data.name} (객실 {len(data.rooms)}개)", file=sys.stderr)
if args.do_print:
_print_stay(data)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(asdict(data), f, ensure_ascii=False, indent=2)
print(f"\n완료: 결과를 {args.output} 에 저장했습니다.")
if __name__ == "__main__":
main()

View File

@ -250,7 +250,7 @@ async def run_build(job: dict) -> dict:
site.published_at = site.published_at or now
links = await _load_links(place_id)
payload_path = emit_payload(place, snapshot, site, version, links)
payload_path = await emit_payload(place, snapshot, site, version, links)
if not payload_path:
return await _fail("payload 를 쓰지 못했다 — 렌더러에 넘길 입력이 없다")
result["payload_path"] = payload_path

View File

@ -2,6 +2,7 @@
수집 결과는 사용자가 승인하기 전까지 사이트에 노출하지 않는다.
"""
import re
import uuid
from common.database.db_session_manager import DB_SESSION_MNG
@ -14,6 +15,7 @@ from crud.fact_crud import FactCRUD
from crud.place_crud import PlaceCRUD
from common.category_schema import get_schema
from services.collector import AdapterDisabled, AdapterNotFound, REGISTRY
from services.collector import yanolja_adapter
from services.external import naver_place_lookup, perplexity, tour_lookup
from services.fact_service import FactService
from router.v1.fact.protocol import Req_UpsertFact
@ -107,9 +109,6 @@ async def discover_official_site(place, place_id: str) -> str:
이건 네이버가 업소 레코드에 달아 값이고, 동일 업소 판정(`pick_match`)
통과했을 때만 쓴다. 그래서 `discover_naver_place` 같은 근거로 자동 확정한다.
수집 금지 호스트도 **등록은 한다.** 인스타그램·OTA robots 크롤을 막지만
(`static_html_adapter._DENY_HOSTS`), 발행본의 공식 채널·sameAs 로는 유효한 사실이다.
크롤 대상이 되어도 어댑터가 손들면 링크만 건너뛴다.
"""
from services.external import naver as naver_client
@ -189,6 +188,53 @@ async def discover_tour_api(place, place_id: str) -> str:
return "resolved" if added else "already"
def _same_business(place_name: str, picked_name: str) -> bool:
"""상호 문자열이 같은 업소를 가리키는지 느슨하게 판정한다.
야놀자는 상호로 정확히 질의하는 공식 API 없다 주소 검색 결과가 진짜
업소인지 상호 비교로 확인한 뒤에만 자동 확정한다."""
def norm(s: str) -> str:
return re.sub(r"[\s,.\-·]+", "", s or "").lower()
p, q = norm(place_name), norm(picked_name)
return bool(p) and bool(q) and (p in q or q in p)
async def discover_yanolja(place, place_id: str) -> str:
"""주소로 야놀자(NOL) 상세페이지를 검색해 등록한다. 반환 규약은 discover_naver_place 와 같다.
상호로 직접 질의하는 공식 API 없어 주소 검색 결과에 의존한다. 그래서
`discover_naver_place`·`discover_tour_api` 처럼 "해석"이라 부르기엔 근거가 약하다
검색 결과 상호가 place.name 겹치는지(`_same_business`) 확인했을 때만 자동 확정하고,
아니면 등록하지 않는다(남의 가게가 섞이는 것을 막는다).
"""
address = place.road_address or place.address
if not address:
return "not_found"
try:
found = await yanolja_adapter.search_by_address(address)
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다
LOG.w(f"[collect] 야놀자 검색 실패(계속): {type(ex).__name__}: {ex}")
return "error"
if not found:
LOG.i(f"[collect] 야놀자 검색결과 없음 (place={place_id})")
return "not_found"
url, picked_name = found
if not _same_business(place.name, picked_name):
LOG.i(f"[collect] 야놀자 검색결과 상호 불일치 — 자동 등록 안 함 ({place.name!r} vs {picked_name!r})")
return "not_found"
added = await _add_link(place_id, LinkChannel.YANOLJA, url, f"{place.name} 야놀자", SourceType.API)
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_links(place, place_id: str, *, include_perplexity: bool = False) -> dict:
"""채널 URL 을 찾아 place_channels 에 적재한다(미확정 상태).
@ -230,6 +276,16 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
LOG.w(f"[collect] 자체 홈페이지 조회 실패(계속): {type(ex).__name__}: {ex}")
stat["official_site"] = "error"
# 야놀자(NOL) — 숙박 업종에서만 의미가 있고, 상호 대조 실패 시 등록하지 않는다(위 함수 참고).
if PlaceCategory(place.category) == PlaceCategory.LODGING:
try:
stat["yanolja"] = await discover_yanolja(place, place_id)
if stat["yanolja"] == "resolved":
stat["discovered"] += 1
except Exception as ex: # noqa: BLE001
LOG.w(f"[collect] 야놀자 조회 실패(계속): {type(ex).__name__}: {ex}")
stat["yanolja"] = "error"
# 오직 요청 옵션으로만 연다. 서버 env 로 일괄 활성화하면 일반 크롤링·재수집에서도
# 사용자가 모르는 유료 검색이 반복될 수 있으므로 COLLECT_USE_PERPLEXITY 는 더 쓰지 않는다.
if not include_perplexity:

View File

@ -168,6 +168,44 @@ class NaverPlaceAdapter:
booking_url=booking_url,
)
async def fetch_summary(self, url: str) -> Optional[dict]:
"""이름·주소·좌표·대표사진 요약 — area_contents(주변 맛집 카드) 적재용.
fetch() 별개 계약이다. fetch() fact/media(사업장 자신의 사실) 돌려주고,
이건 "이 업체가 누구인가" 필요한 호출자(주변 맛집 보강) 위한 것이다.
services/place_service.py verify_place_by_url 이미 같은 필드
(name/roadAddress/address/coordinate.x·y) 같은 방식으로 읽는다 필드명은 거기서 확인됐다.
사진은 fetch() 쓰는 _to_media() 그대로 재사용한다 이미 받아온 state 에서
꺼낼 뿐이라 네트워크 호출이 추가로 들지 않는다.
"""
try:
place_id = await self._resolve_place_id(url)
state = await self._load_state(place_id)
except Exception as ex:
LOG.w(f"[naver_place] 요약 조회 실패: {ex}")
return None
base = state.get(f"PlaceDetailBase:{place_id}") or next(
(v for k, v in state.items() if k.startswith("PlaceDetailBase")), None
)
name = str((base or {}).get("name") or "").strip()
if not base or not name:
return None
coord = base.get("coordinate") or {}
summary = {"place_id": place_id, "name": name}
address = str(base.get("roadAddress") or base.get("address") or "").strip()
if address:
summary["address"] = address
if coord.get("y"):
summary["latitude"] = str(coord["y"])
if coord.get("x"):
summary["longitude"] = str(coord["x"])
media = self._to_media(state)
if media:
summary["imageUrl"] = media[0].origin_url
return summary
# ---- 내부 ----------------------------------------------------------
@staticmethod

View File

@ -8,6 +8,8 @@
static_html 사장님이 확정한 자기 홈페이지. JSON-LD·OpenGraph 읽고 robots.txt 따른다.
docs/DECISIONS.md 1-1 보류했던 어댑터로, 2026-08-28 실측 결론
(docs/DATA_SOURCE_RESEARCH.md) 따라 **사장님 확정 URL 한정**으로 등록했다.
yanolja 야놀자(NOL) 국내숙소 상세페이지. Next.js CSR 페이지라 Playwright 렌더링해
읽는다. 캡차 우회·IP 회전 등은 하지 않으며, 차단되면 그대로 실패로 돌린다.
mock mock:// 전용. 네트워크 없이 파이프라인 전체를 돌리는 테스트용.
켜고 끄는 것은 환경변수 COLLECT_ADAPTERS (쉼표 구분). 코드를 고치지 않고 채널만
@ -27,10 +29,11 @@ from services.collector.mock_adapter import MockAdapter
from services.collector.naver_place_adapter import NaverPlaceAdapter
from services.collector.static_html_adapter import StaticHtmlAdapter
from services.collector.tour_api_adapter import TourApiAdapter
from services.collector.yanolja_adapter import YanoljaAdapter
# 이 환경에서 켜둘 어댑터 id 집합. 등록돼 있어도 여기 없으면 AdapterDisabled 로 막힌다.
ENABLED_ADAPTERS: frozenset = frozenset(
x.strip() for x in os.getenv("COLLECT_ADAPTERS", "mock,naver_place,tour_api,static_html").split(",") if x.strip()
x.strip() for x in os.getenv("COLLECT_ADAPTERS", "mock,naver_place,tour_api,static_html,yanolja").split(",") if x.strip()
)
@ -82,6 +85,7 @@ def _register_default() -> AdapterRegistry:
# 앞에 두면 네이버 플레이스 URL 까지 이쪽으로 빨려 들어간다.
registry.register(NaverPlaceAdapter())
registry.register(TourApiAdapter())
registry.register(YanoljaAdapter())
registry.register(MockAdapter())
registry.register(StaticHtmlAdapter())
return registry

View File

@ -1,27 +1,9 @@
"""정적 HTML 어댑터 — **사장님이 확정한 자기 홈페이지** 전용.
어댑터가 지금 등록되는가 (docs/DATA_SOURCE_RESEARCH.md, 2026-08-28)
docs/DECISIONS.md 1-1 "약관·robots.txt 기준 허용 범위" 결론 전까지 등록을 보류했던
어댑터다. 실측 결론은 이렇다.
야놀자·여기어때 HTTP 403 + Cloudflare 챌린지. 기술적으로 막혔고, 같은 행위에
민사 10 배상 선례가 있다(야놀자 v 여기어때, 서울중앙지법 2021-08).
네이버·카카오 robots.txt `Disallow: /`. 명시적 불허.
사장님 자체 홈페이지 사장님이 URL 확정해 주고, 사실의 주인도 사장님이다. **가능.**
어댑터의 정당성은 전부 "사장님이 확정한 URL 만 본다" 에서 나온다.
전제가 깨지면(플랫폼 URL 흘러들어오면) 정당성도 같이 깨지므로,
아래 _DENY_HOSTS **구조적으로** 막는다. 운영자가 실수로 넣어도 긁힌다.
표본 근거 어댑터가 숙박에서 특히 값이 큰가
네이버 지역검색 `link` 필드 충전율(업종별 25 표본): 숙박 96% 자체 도메인 19,
음식점 64%, 카페 92% 인스타 17. 숙박은 자체 홈페이지 보유율이 3업종 가장 높다.
**금지 (docs/DECISIONS.md 1-1, 결론과 무관하게 영구)**
캡차 우회 · 탐지 우회 · IP 회전. 여기에 하나
**robots.txt 확인하고 그대로 따른다.** 사장님 홈페이지라도 예외 없다.
막히면 실패로 돌려주고 폴백 3단계로 간다(공식 API 사장님 붙여넣기 최소 정보 생성).
값을 만드는 원칙
구조화된 (JSON-LD schema.org, OpenGraph) fact 올린다. 본문 텍스트에서
키워드를 주워 억지로 매핑하지 않는다 유일한 예외가 체크인·체크아웃 시각인데,
@ -60,15 +42,9 @@ HEADERS = {
"Accept-Language": "ko-KR,ko;q=0.9",
}
# ★ 이 호스트들은 이 어댑터가 절대 건드리지 않는다.
# - 전용 어댑터가 따로 있거나(naver_place)
# - 실측·판례로 수집 불가 결론이 난 곳이거나(야놀자·여기어때·카카오맵)
# - 공식 OAuth 로만 가져와야 하는 곳(인스타그램)이다.
# can_handle 에서 걸러 AdapterNotFound 로 떨어뜨린다.
_DENY_HOSTS = (
"naver.com", "naver.me", # 플레이스·지도·블로그·예약 — robots Disallow: /
"kakao.com", "daum.net", # 카카오맵 — robots Disallow, 내부 API 406
"yanolja.com", "goodchoice.kr", # OTA — 403 + 민사 10억 선례
"dailyhotel.com", "catchtable.co.kr",
"airbnb.co.kr", "airbnb.com",
"booking.com", "agoda.com", "expedia.co.kr",

View File

@ -0,0 +1,328 @@
"""야놀자(NOL) 국내숙소 어댑터 — Playwright 로 상세페이지를 렌더링해 객실·사진을 수집한다.
nol.yanolja.com Next.js CSR 페이지라 httpx(정적 HTML)로는 읽는다 그래서
static_html_adapter 아니라 어댑터가 Playwright 실제 브라우저 렌더링을 거친다.
이건 탐지 우회가 아니라 JS 렌더링이 필요한 페이지를 읽는 통상적인 방법이다
캡차 우회·IP 회전·지문 위장 같은 하지 않는다(registry.py 영구 금지 원칙 그대로 유지).
차단(403·챌린지 ) 만나면 그대로 실패로 돌려주고 재시도·우회하지 않는다.
수집 원칙
- 페이지에 보이는 값만 옮긴다. 가격은 수집하지 않는다(불안정하고 예약 시점에 따라 바뀐다).
- 이미지는 원본 URL 그대로만 남긴다(origin_url) 재게시 여부는 발행 게이트가 판단한다.
- 객실(unit) 값은 scope="unit" 담는다. 숙소소개·시설/서비스·이용안내·예약공지는
전부 크롤링하지만 fact 만들지 않는다 숙소소개(intro)·객실소개(room_intro)
allow_llm=True 필드라 그대로 넣으면 절대규칙 7 어기고, 나머지 셋은 스키마의
특정 필드와 1:1 맞는다. RawSource.text 보존한다. 시설/서비스·이용안내·
예약공지는 확정 링크의 payload.links[].stayGuide로도 전달해 원문을 표시한다.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Optional
from playwright.async_api import Page, TimeoutError as PWTimeoutError, async_playwright
from common.enums import LinkChannel
from common.logger import LOG
from services.collector.base import CollectedFact, CollectedMedia, RawSource
SEARCH_URL = "https://nol.yanolja.com/"
DETAIL_URL_RE = re.compile(r"nol\.yanolja\.com/stay/domestic/\d+", re.IGNORECASE)
# 검색결과 카드는 <a data-card-type="basic" ... aria-label="{업체명} 상품 상세 보기" href="...">
RESULT_CARD_SELECTOR = 'a[data-card-type="basic"]'
SECTION_IDS = {
"rooms": "PLACE_SECTION",
"overview": "OVERVIEW_SECTION",
"service": "SERVICE_SECTION",
"policy": "POLICY_SECTION",
"reservation": "RESERVATION_SECTION",
}
# 원문을 보존할 섹션들. 생성 근거와 확정된 NOL 안내 표시가 같은 수집 원문을 쓴다.
_TEXT_ONLY_SECTIONS = (
("overview", "숙소 소개"),
("service", "시설/서비스"),
("policy", "이용 안내"),
("reservation", "예약 공지"),
)
_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
)
@dataclass
class _RoomInfo:
name: str
description: Optional[str] = None
capacity: Optional[str] = None
bed: Optional[str] = None
images: list[str] = field(default_factory=list)
def _extract_stay_id(url: str) -> Optional[str]:
m = re.search(r"/stay/domestic/(\d+)", url)
return m.group(1) if m else None
def _is_real_photo_url(src: str) -> bool:
return bool(src) and "static/images" not in src
def _parse_capacity(capacity: Optional[str]) -> tuple[Optional[str], Optional[str]]:
if not capacity:
return None, None
std_m = re.search(r"기준\s*(\d+)인", capacity)
max_m = re.search(r"최대\s*(\d+)인", capacity)
return (std_m.group(1) if std_m else None, max_m.group(1) if max_m else None)
def _parse_rooms_from_section_text(section_text: str) -> list[_RoomInfo]:
"""PLACE_SECTION.innerText 는 "N / M"(사진 장수) 로 객실 카드 수만큼 반복된다.
가격·예약 버튼 이하는 무시한다. 사이트 구조가 바뀌면 정규식도 손봐야 한다."""
rooms: list[_RoomInfo] = []
chunks = re.split(r"\n?\d+\s*\n/\n\d+\n", section_text)
for chunk in chunks[1:]:
lines = [l.strip() for l in chunk.split("\n") if l.strip()]
if not lines:
continue
name = lines[0]
capacity_m = re.search(r"기준\s*\d+인\s*/\s*최대\s*\d+인", chunk)
bed_m = re.search(r"(킹|퀸|더블|싱글|트윈)\s*침대\s*\d+개", chunk)
desc_lines = []
for l in lines[1:]:
if l.startswith("기준") or "체크인" in l or l == "숙박":
break
desc_lines.append(l.strip("()"))
rooms.append(
_RoomInfo(
name=name,
description=" ".join(desc_lines) if desc_lines else None,
capacity=capacity_m.group(0) if capacity_m else None,
bed=bed_m.group(0) if bed_m else None,
)
)
return rooms
async def _scroll_through_page(page: Page) -> None:
prev_height = -1
for _ in range(30):
await page.mouse.wheel(0, 1200)
await page.wait_for_timeout(250)
cur_height = await page.evaluate("document.body.scrollHeight")
if cur_height == prev_height:
break
prev_height = cur_height
await page.evaluate("window.scrollTo(0, 0)")
async def _get_section_text(page: Page, element_id: str) -> str:
try:
await page.evaluate(
"(id) => { const el = document.getElementById(id); if (el) el.scrollIntoView({block:'center'}); }",
element_id,
)
await page.wait_for_timeout(400)
return await page.evaluate(
"(id) => { const el = document.getElementById(id); return el ? el.innerText : ''; }",
element_id,
) or ""
except Exception:
return ""
async def _get_room_images_by_name(page: Page, element_id: str) -> list[tuple[str, list[str]]]:
"""카드 구조가 "사진 캐러셀 → <h2>객실명</h2> → 설명" 순이라, h2 를 만나기 전까지
쌓인 이미지가 h2 몫이다."""
try:
await page.evaluate(
"(id) => { const el = document.getElementById(id); if (el) el.scrollIntoView({block:'center'}); }",
element_id,
)
await page.wait_for_timeout(400)
raw = await page.evaluate(
"""(id) => {
const el = document.getElementById(id);
if (!el) return [];
const nodes = el.querySelectorAll('img, h2');
const result = [];
let buf = [];
for (const n of nodes) {
if (n.tagName === 'IMG') {
if (n.src) buf.push(n.src);
} else if (n.tagName === 'H2') {
result.push({name: n.textContent.trim(), images: [...new Set(buf)]});
buf = [];
}
}
return result;
}""",
element_id,
) or []
except Exception:
return []
return [(item["name"], [u for u in item["images"] if _is_real_photo_url(u)]) for item in raw]
async def _get_section_images(page: Page, element_id: str) -> list[str]:
try:
await page.evaluate(
"(id) => { const el = document.getElementById(id); if (el) el.scrollIntoView({block:'center'}); }",
element_id,
)
await page.wait_for_timeout(400)
srcs = await page.evaluate(
"""(id) => {
const el = document.getElementById(id);
if (!el) return [];
return [...new Set(Array.from(el.querySelectorAll('img')).map(i => i.src).filter(Boolean))];
}""",
element_id,
) or []
except Exception:
return []
return [u for u in srcs if _is_real_photo_url(u)]
async def _get_stay_name(page: Page) -> Optional[str]:
try:
h1 = await page.query_selector("h1")
return (await h1.inner_text()).strip() if h1 else None
except Exception:
return None
async def _new_page():
"""호출마다 브라우저를 새로 띄우고 닫는다 — 다른 어댑터처럼 상태를 들고 있지 않는다."""
pw = await async_playwright().start()
browser = await pw.chromium.launch(headless=True)
context = await browser.new_context(locale="ko-KR", user_agent=_UA)
page = await context.new_page()
return pw, browser, page
async def search_by_address(address: str, timeout_ms: int = 15000) -> Optional[tuple[str, str]]:
"""주소로 검색해 첫 검색결과의 (상세 URL, 업체명)을 돌려준다. 못 찾으면 None.
discover 단계(주소만 아는 상태에서 링크를 찾는 ) 쓴다. 카드를 클릭하지 않고
href 직접 읽어 이동한다 클릭 탭이 뜨거나 배너에 가로채이는 문제를 피한다.
"""
pw, browser, page = await _new_page()
try:
await page.goto(SEARCH_URL, wait_until="domcontentloaded")
search_box = page.get_by_role("combobox", name="검색어 입력")
await search_box.click()
await search_box.fill(address)
await search_box.press("Enter")
try:
first_card = page.locator(RESULT_CARD_SELECTOR).first
await first_card.wait_for(state="visible", timeout=timeout_ms)
except PWTimeoutError:
return None
name = await first_card.get_attribute("aria-label") or await first_card.inner_text()
detail_url = await first_card.get_attribute("href")
if not detail_url:
return None
await page.goto(detail_url, wait_until="domcontentloaded")
return page.url, name
except Exception as ex:
LOG.w(f"[yanolja] 주소 검색 실패(계속): {type(ex).__name__}: {ex}")
return None
finally:
await browser.close()
await pw.stop()
class YanoljaAdapter:
"""야놀자(NOL) 국내숙소 상세페이지 → 객실 fact·사진."""
id = "yanolja"
def can_handle(self, url: str) -> bool:
return bool(DETAIL_URL_RE.search((url or "").lower()))
async def fetch(self, url: str) -> RawSource:
pw, browser, page = await _new_page()
try:
await page.goto(url, wait_until="domcontentloaded", timeout=60000)
try:
await page.wait_for_selector("h1", timeout=15000)
except PWTimeoutError:
# ★ 우회하지 않는다 — 차단·비정상 응답이면 그대로 실패로 돌린다.
pass
name = await _get_stay_name(page)
if not name:
return RawSource.failure(url, self.id, "상세페이지를 읽지 못했다(차단되었거나 존재하지 않는 페이지)", LinkChannel.YANOLJA)
await _scroll_through_page(page)
rooms_text = await _get_section_text(page, SECTION_IDS["rooms"])
rooms = _parse_rooms_from_section_text(rooms_text)
room_images = await _get_room_images_by_name(page, SECTION_IDS["rooms"])
if len(room_images) == len(rooms):
for room, (_, images) in zip(rooms, room_images):
room.images = images
else:
images_by_name = dict(room_images)
for room in rooms:
room.images = images_by_name.get(room.name, [])
gallery = await _get_section_images(page, SECTION_IDS["overview"])
# 숙소소개·시설서비스·이용안내·예약공지 — fact 로 만들 스키마 필드가 없어
# RawSource.text 로만 싣는다(생성 근거). intro 계열은 allow_llm=True 라
# fact 로 만들면 안 된다(절대규칙 7, 2026-08-31 사고: TourAPI 가 원문을 그대로
# intro 로 밀어넣어 LLM 소개문을 영영 못 보이게 만들었다).
section_texts: list[str] = []
for section_key, label in _TEXT_ONLY_SECTIONS:
body = await _get_section_text(page, SECTION_IDS[section_key])
if body.strip():
section_texts.append(f"[{label}]\n{body.strip()}")
page_text = "\n\n".join(section_texts)[:20000]
except Exception as ex:
return RawSource.failure(url, self.id, f"{type(ex).__name__}: {ex}", LinkChannel.YANOLJA)
finally:
await browser.close()
await pw.stop()
facts: list[CollectedFact] = []
media: list[CollectedMedia] = []
for src in gallery:
media.append(CollectedMedia(origin_url=src, label="갤러리"))
for room in rooms:
std_cap, max_cap = _parse_capacity(room.capacity)
facts.append(CollectedFact(key="room_type", value=room.name, scope="unit", unit_name=room.name))
if std_cap:
facts.append(CollectedFact(key="standard_capacity", value=std_cap, scope="unit", unit_name=room.name))
if max_cap:
facts.append(CollectedFact(key="max_capacity", value=max_cap, scope="unit", unit_name=room.name))
if room.bed:
facts.append(CollectedFact(key="bed_type", value=room.bed, scope="unit", unit_name=room.name))
# ★ room_intro 도 allow_llm=True 라 수집하지 않는다(위 intro 와 같은 이유).
for src in room.images:
media.append(CollectedMedia(origin_url=src, label="객실 사진", unit_name=room.name))
if not facts and not media:
return RawSource.failure(url, self.id, "객실·소개 정보를 찾지 못했다", LinkChannel.YANOLJA)
LOG.i(f"[yanolja] {name} — 객실 {len(rooms)}개 · fact {len(facts)}건 · 사진 {len(media)}")
return RawSource(
url=url,
adapter_id=self.id,
channel=LinkChannel.YANOLJA,
text=page_text,
facts=facts,
media=media,
)

View File

@ -10,6 +10,7 @@
한때 가지가 파일 500줄에 뭉쳐 있었다. "FAQ 답이 이상하다" 고치러 와도
어디를 봐야 할지가 파일 안에서 갈리지 않았다.
"""
import hashlib
import json
from dataclasses import dataclass, field
from typing import Optional
@ -197,6 +198,71 @@ async def generate_copy(
return result
# ── 요약(summarize_text) ──────────────────────────────────────────────────
# ★ generate_copy 와 다르다: 여기서 압축하는 문장은 **이미 승인된 값**이다(fact 로 저장된 intro·
# room_intro). 새 사실을 만드는 게 아니라 같은 내용을 짧게 쓰는 것뿐이라 ground_check 를 다시
# 걸지 않는다 — "사실을 더하지 마라"는 프롬프트 지시로 충분하다.
_SUMMARY_CACHE: dict[str, str] = {}
_SUMMARY_CACHE_MAX = 500
_SUMMARY_PROMPT = (
"다음 숙소 소개에서 핵심 특징 1~2개만 골라 한국어 한 문장, 공백 포함 60~80자로 요약해줘. "
"원문에 없는 사실이나 과장 표현을 추가하지 말고, 선택한 사실의 조건과 부정 표현을 유지해. "
"반복되는 상호명, 인사말, 홍보 수식어는 생략해. "
"요약문만 출력하고 다른 말은 붙이지 마.\n\n"
)
async def summarize_text(
text: str,
*,
model: str = DEFAULT_TEXT_MODEL,
max_retries: int = 2,
client: Optional[httpx.AsyncClient] = None,
) -> Optional[str]:
"""캔버스 미리보기용 축약문. 실패해도 예외를 올리지 않는다 — 호출측은 None 이면 원문을 쓴다.
DB 남기지 않는다. 같은 원문은 프로세스 메모리 캐시(sha256 ) 재호출을 막는다
(서버 재시작하면 비워진다 요구사항: "DB 저장은 생략하고 프론트 응답에만 실어준다").
"""
stripped = text.strip()
if not stripped:
return None
if not is_configured():
return None
# 길이 기준을 바꾼 뒤 이전 길이의 요약을 재사용하지 않도록 프롬프트도 키에 넣는다.
cache_key = hashlib.sha256((_SUMMARY_PROMPT + stripped).encode("utf-8")).hexdigest()
cached = _SUMMARY_CACHE.get(cache_key)
if cached is not None:
return cached
body = {
"contents": [{"role": "user", "parts": [{
"text": _SUMMARY_PROMPT + stripped,
}]}],
"generationConfig": {"temperature": 0.2},
}
owns_client = client is None
client = client or httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0))
try:
payload = await call(client, model, body, max_retries)
summary = extract_text(payload).strip()
except GeminiError as ex:
LOG.w(f"[gemini-text] 요약 실패: {ex}")
return None
finally:
if owns_client:
await client.aclose()
if not summary:
return None
if len(_SUMMARY_CACHE) >= _SUMMARY_CACHE_MAX:
_SUMMARY_CACHE.clear() # 간단한 캐시 상한 — 관리 도구 트래픽 규모에는 LRU 가 과하다.
_SUMMARY_CACHE[cache_key] = summary
return summary
@dataclass
class GeneratedSong:
"""가사 생성 결과. 곡은 여기서 만들지 않는다 — 작곡은 services/external/suno 다."""

View File

@ -0,0 +1,57 @@
"""지역명으로 맛집 후보 이름을 찾는다.
services/external/perplexity.py(채널 발견: 단일 업소 URL)와는 다른 용도다
여기는 "이 지역에 뭐가 있나" 묻는 지역 목록 검색이다. 원칙은 같다:
Perplexity 응답을 사실로 쓰지 않는다. 이름만 후보로 받고, 실제 값은 이후
네이버 크롤링(NaverPlaceAdapter) 확정한다.
"""
import json
from common.logger import LOG
from services.llm.perplexity import DEFAULT_MAX_TOKENS, DEFAULT_MODEL, PerplexityError, call
from services.prompts.restaurant_search import RESPONSE_SCHEMA, SYSTEM_PROMPT, build_prompt
MAX_RESULTS = 10
def _parse_names(payload: dict) -> list[str]:
choices = payload.get("choices") or []
if not choices or not isinstance(choices[0], dict):
return []
content = ((choices[0].get("message") or {}).get("content")) or ""
try:
doc = json.loads(content)
except (json.JSONDecodeError, TypeError):
LOG.w("[restaurant_discovery] 구조화 출력 파싱 실패")
return []
names = doc.get("restaurants")
if not isinstance(names, list):
return []
return [str(n).strip() for n in names if str(n or "").strip()][:MAX_RESULTS]
async def search_region_restaurants(
region_label: str, *, model: str = DEFAULT_MODEL, client=None,
) -> list[str]:
"""지역명으로 맛집 상위 10곳의 이름만 받는다.
실패(미설정·타임아웃·5xx)하면 목록 호출측이 TourAPI 결과만으로 계속 진행한다.
"""
if not (region_label or "").strip():
return []
body = {
"model": model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": build_prompt(region_label)},
],
"max_tokens": DEFAULT_MAX_TOKENS,
"temperature": 0,
"response_format": RESPONSE_SCHEMA,
}
try:
payload = await call(body, client=client)
except PerplexityError as ex:
LOG.w(f"[restaurant_discovery] '{region_label}' 검색 실패: {ex}")
return []
return _parse_names(payload)

View File

@ -33,6 +33,7 @@ from router.v1.fact.protocol import (
Res_FactList,
)
from services.external.gemini_extract import extract_facts
from services.intro_summary import summarize_intro
from services.llm.gemini import GeminiError, GeminiNotConfigured
# 사장님이 붙여넣은 원문의 출처 표기. ★ 실제 URL 이 아니라 경로 식별자다 —
@ -40,7 +41,7 @@ from services.llm.gemini import GeminiError, GeminiNotConfigured
# '어디서 왔는가' 는 여전히 명확하다: 사장님이 화면에 직접 붙여넣었다.
OWNER_PASTE_SOURCE = "owner:paste"
# 자동 수집 출처 — 노출값을 직접 바꾸지 못하고 후보로만 들어간다.
# 자동 출처. CRAWL과 LLM 문장은 아래의 즉시 노출 경로를 먼저 거친다.
_AUTO_SOURCES = (SourceType.API, SourceType.CRAWL, SourceType.LLM)
@ -48,10 +49,9 @@ class FactService:
"""fact 기록 + 검증 상태 전이.
갈래 프로세스
생성 : 자동 수집 후보(UNVERIFIED) 사람이 승인 노출값(VERIFIED) 사이트 빌드
업데이트: 재수집 값이 같으면 검증 유지(REFRESHED)
값이 다르면 **노출값은 그대로 두고** 후보(PENDING_OWNER) 적재
사람이 승인해야 노출값이 교체된다
생성 : 크롤링 노출값(VERIFIED), API 후보 사람이 승인
업데이트: 같은 값이면 검증 유지(REFRESHED). 다른 CRAWL 값은 바로 교체한다.
API 값이나 사람 입력·정정본과 충돌하는 값은 후보(PENDING_OWNER) 적재한다.
수정 : 사람이 직접 입력 노출값 즉시 교체. 정정(CORRECTED) 잠금 표시가 붙는다
문장 : LLM 소개문·메타(allow_llm 필드) 승인 없이 바로 노출값
(사실이 아니라 **이미 승인된 사실로 문장**이다 upsert_fact 주석)
@ -63,8 +63,8 @@ class FactService:
1. key 사업장 업종 스키마에 있는 것만 (FACT_INVALID_KEY)
2. owner 아닌 출처는 source_url 필수 (FACT_SOURCE_REQUIRED)
3. LLM 스키마가 허용한 문장 필드에만 쓴다 (절대규칙 7)
4. 자동 수집은 노출값을 직접 바꾼다 후보로만 (절대규칙 1·6).
예외는 LLM 문장 하나뿐이고, 그것도 CORRECTED 덮는다
4. 크롤링은 승인 없이 노출한다. 사람 입력·정정본은 덮지 않는다.
LLM 문장도 즉시 노출하되 CORRECTED는 보존한다.
5. 상태 전이는 FACT_STATUS_TRANSITIONS 있는 것만
"""
@ -133,12 +133,21 @@ class FactService:
res.result.SetResult(list_err)
return res
res.facts = [FactData.model_validate(r) for r in rows]
await self._attach_summaries(res.facts)
# ★ 사이트에 나갈 수 있는 건수. 발행 게이트가 보는 숫자와 같은 기준이다.
res.publishable = sum(1 for r in rows if FactStatus(r.status) in PUBLISHABLE_FACT_STATUSES)
# 재수집이 올려놓은 확인 대기 건수 — 관리 화면의 '검토할 것' 배지.
res.pending_review = sum(1 for r in rows if FactStatus(r.status) == FactStatus.PENDING_OWNER)
return res
async def _attach_summaries(self, facts: list) -> None:
"""intro/room_intro 원문이 길면 캔버스 미리보기용 요약을 얹는다.
place_facts 에는 저장하지 않는다 응답(FactData.summary)에만 실린다.
원문이 짧으면 API 부르지 않는다(summarize_intro)."""
for f in facts:
f.summary = await summarize_intro(f.key, f.value)
# ---- 기록 ----
async def extract_from_text(self, user_info: UserInfo, place_id: str, text: str) -> Res_ExtractFacts:
"""사장님이 붙여넣은 원문 → fact 후보.
@ -237,7 +246,8 @@ class FactService:
"""fact 를 기록한다. 출처에 따라 경로가 갈린다.
사람(owner) 노출값을 직접 교체한다(수정 프로세스)
자동 수집 노출값과 같으면 확인 시각만 갱신, 다르면 후보로 적재(업데이트 프로세스)
크롤링 원값을 바로 노출, 직접 입력·정정본과 충돌하면 후보로 보존
API 노출값과 같으면 확인 시각만 갱신, 다르면 후보로 적재
"""
res = Res_Fact()
err_type, place = await self._load_place(user_info, place_id)
@ -285,6 +295,17 @@ class FactService:
now = GTime.UTC()
same_value = published is not None and (published.value or "") == (req.value or "")
# 2026-09-14: 크롤링 원값을 바로 표시한다. 사람 입력의 출처까지 바뀌지 않게
# 동일값 갱신보다 먼저 보호한다.
if req.source_type == SourceType.CRAWL and published is not None and (
published.source_type == SourceType.OWNER.value
or FactStatus(published.status) in LOCKED_FACT_STATUSES
):
if same_value:
res.outcome = FactWriteOutcome.REFRESHED
return await self._reload(res, pid, published.fact_id)
return await self._write_candidate(res, pid, req, published, now, spec)
# ── 값이 그대로다 — 검증을 초기화하지 않고 '언제 다시 확인했는지'만 갱신 ──
if same_value:
run_err, _rc = await DB_SESSION_MNG.execute_lambda_claim(
@ -311,7 +332,7 @@ class FactService:
# ★ 단 사장님이 고친 문장(CORRECTED)은 덮지 않는다. 지금까지 이 잠금은 "자동 출처는
# 노출값에 손을 못 댄다" 는 경로 자체가 지켜 줬는데(_write_candidate), LLM 만 경로를
# 바꾸면 그 보호가 사라진다 — 잠금은 여기서 명시적으로 다시 건다(절대규칙 6).
if req.source_type == SourceType.LLM:
if req.source_type in (SourceType.LLM, SourceType.CRAWL):
if published is not None and FactStatus(published.status) in LOCKED_FACT_STATUSES:
return await self._write_candidate(res, pid, req, published, now, spec)
return await self._replace_published(res, place_id, pid, req, published, now, spec, user_info)
@ -373,9 +394,9 @@ class FactService:
return res
async def _replace_published(self, res, place_id, pid, req, published, now, spec, user_info):
"""사람이 직접 입력 — 노출값을 즉시 교체한다(수정 프로세스).
"""직접 입력·크롤링·허용된 LLM 문장을 노출값으로 교체한다.
사람이 넣은 값은 사람이 출처이자 책임 주체라 별도 확인 단계를 두지 않는.
크롤링 자동 노출은 verified_by를 비워 사람이 승인한 이력과 구별한.
기존 노출값은 지우지 않고 EXPIRED 이력으로 남긴다."""
fact = place_facts(
place_id=pid,
@ -386,7 +407,7 @@ class FactService:
source_type=req.source_type.value,
source_url=(req.source_url or None),
collected_at=now,
verified_by=uuid.UUID(user_info.user_id),
verified_by=None if req.source_type == SourceType.CRAWL else uuid.UUID(user_info.user_id),
verified_at=now,
status=FactStatus.VERIFIED.value,
expires_at=req.expires_at,
@ -396,6 +417,8 @@ class FactService:
[
lambda s: self._expire_then_ok(s, pid, req, now),
lambda s: self.crud.add_fact(s, fact),
*([lambda s: self._reject_others_ok(s, pid, fact, now, fact.fact_id)]
if req.source_type == SourceType.CRAWL else []),
],
)
if run_err != ErrorType.SUCCESS:

View File

@ -178,9 +178,17 @@ def _stop_names(course: dict) -> list[str]:
return out
def stop_signature(course: dict) -> frozenset[str]:
"""코스의 정거장 집합 — 중복 판정 기준. 호출 하나를 넘어 여러 번의 재시도에 걸쳐
같은 코스를 다시 채택하지 않으려면, 이전에 채택한 코스의 signature 다음 호출의
`already_seen` 실어 보내야 한다(`itinerary_llm_service` 그렇게 누적한다)."""
return frozenset(_stop_names(course))
def parse_courses(
payload: dict, duration: str, place_name: str,
place_lat: float | None = None, place_lng: float | None = None,
already_seen: set[frozenset[str]] | None = None,
) -> tuple[list[dict], list[str]]:
"""(쓸 수 있는 코스, 버린 이유) — 버린 이유는 로그와 잡 결과에 남긴다.
@ -189,6 +197,10 @@ def parse_courses(
place_name·place_lat·place_lng 업소를 정거장으로 넣을 쓴다(`_apply_schedule`)
모델에게 묻지 않는다. 좌표가 없으면(아직 지오코딩 ) 이름만 들어가고 핀은 찍힌다.
already_seen **이전 호출**(재시도)에서 이미 채택한 코스들의 `stop_signature` .
10개를 채우려고 같은 프롬프트로 다시 부르면 모델이 앞서 것과 겹치는 코스를
있다 payload 안에서만 중복을 보면 그걸 새로 채택해 버린다.
"""
text = _FENCE_RE.sub("", _payload_text(payload)).strip()
if not text:
@ -209,7 +221,7 @@ def parse_courses(
fallback = _first_source(payload)
out: list[dict] = []
dropped: list[str] = []
seen_stop_sets: list[frozenset[str]] = []
seen_stop_sets: list[frozenset[str]] = list(already_seen or ())
for raw in raw_items:
if not isinstance(raw, dict):

View File

@ -0,0 +1,10 @@
"""정보 조회와 발행 payload가 같은 조건으로 소개문 요약을 사용한다."""
from services.external import gemini_text
async def summarize_intro(key: str, value: str | None) -> str | None:
# 짧은 소개까지 유료 호출하지 않는다. 원문은 DB에 그대로 두고 응답만 보강한다.
text = (value or "").strip()
if key not in {"intro", "room_intro"} or len(text) <= 80:
return None
return await gemini_text.summarize_text(text)

View File

@ -33,6 +33,14 @@ from services.prompts import itinerary as prompts
# ★ 이야기(240초)와 같은 값이다. 2박 3일이 48초까지 갔고 변동이 크다(실측 2026-09-11).
_TIMEOUT = httpx.Timeout(240.0, connect=10.0)
# ★ 10개(컨셉당 2개) — 프롬프트가 요구하는 개수와 같다(`prompts.itinerary._TASK`). 프롬프트만으로는
# 보장이 안 돼(모델이 5개로 회귀할 때가 있다, 위 파일 주석 참고) 여기서 재시도로 채운다.
TARGET_COURSES = 10
# ★ 사장님 지시(2026-09-14): 2회로 제한한다. 늘릴수록 10개를 채울 확률은 오르지만 건당
# 20~50초가 배로 늘어난다 — 못 채우면 채운 만큼만 저장하고 note 로 남긴다(아래 _generate_one).
MAX_ATTEMPTS = 2
_CRUD = PlaceItineraryCRUD()
@ -98,8 +106,12 @@ async def _generate_one(
client: httpx.AsyncClient, place_name: str, region: str, duration: str,
place_lat: float | None = None, place_lng: float | None = None,
):
"""기간 하나. 실패는 예외로 올리지 않고 ( 목록, 이유) 로 돌려준다.
"""기간 하나. 실패는 예외로 올리지 않고 (코스 목록, 이유) 로 돌려준다.
TARGET_COURSES 개를 채울 때까지 같은 프롬프트로 최대 MAX_ATTEMPTS 다시 부른다
번의 호출로 10개가 안정적으로 나온다(`prompts.itinerary` 실측 주석). 이전 시도에서
이미 채택한 코스는 `already_seen` 으로 다음 시도에 넘겨, 재시도가 같은 코스를
채택해 개수만 부풀리지 않게 한다(`grounding.stop_signature`).
place_lat·place_lng 업소를 정거장(출발·복귀)으로 넣을 쓴다(`grounding.parse_courses`)
모델에게 묻지 않는다. 없으면 이름만 들어가고 지도 핀은 찍힌다."""
body = {
@ -110,17 +122,37 @@ async def _generate_one(
],
"max_tokens": prompts.MAX_TOKENS,
}
try:
payload = await perplexity.call(body, client=client)
except perplexity.PerplexityNotConfigured:
return [], ["PERPLEXITY_API_KEY 미설정"]
except perplexity.PerplexityError as ex:
LOG.w(f"[itinerary] {duration} 호출 실패 place={place_name}: {ex}")
return [], [f"호출 실패: {ex}"]
courses, dropped = grounding.parse_courses(payload, duration, place_name, place_lat, place_lng)
LOG.i(f"[itinerary] {place_name} {duration}: {len(courses)}개 채택, {len(dropped)}건 버림")
return courses, dropped
courses: list[dict] = []
seen: set[frozenset[str]] = set()
notes: list[str] = []
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
payload = await perplexity.call(body, client=client)
except perplexity.PerplexityNotConfigured:
return [], ["PERPLEXITY_API_KEY 미설정"]
except perplexity.PerplexityError as ex:
LOG.w(f"[itinerary] {duration} {attempt}차 호출 실패 place={place_name}: {ex}")
notes.append(f"{attempt}차 호출 실패: {ex}")
break # 같은 오류가 반복될 걸 재시도로 밀어붙이지 않는다 — 지금까지 모은 것만 쓴다
new_courses, dropped = grounding.parse_courses(
payload, duration, place_name, place_lat, place_lng, already_seen=seen)
notes += dropped
for course in new_courses:
if len(courses) >= TARGET_COURSES:
break
courses.append(course)
seen.add(grounding.stop_signature(course))
if len(courses) >= TARGET_COURSES:
break
if len(courses) < TARGET_COURSES:
notes.append(f"{MAX_ATTEMPTS}차 시도 후에도 {len(courses)}/{TARGET_COURSES}개만 채웠다")
LOG.i(f"[itinerary] {place_name} {duration}: {len(courses)}개 채택, {len(notes)}건 버림/안내")
return courses, notes
async def ensure_generated(place) -> dict:

View File

@ -230,6 +230,21 @@ class LocalContentService:
if prev is None or prev.body != shared_body:
changed += 1
# ★ TourAPI 가 만들지 않은 연결(예: NAVER_CRAWL — services/local_restaurant_enrichment.py)은
# 이 함수가 모르는 소스다. "이번 응답에 없으니 끊는다"를 그대로 적용하면 다른 파이프라인이
# 붙여 둔 것까지 매 재수집마다 지웠다가 그쪽이 다시 채우는 낭비가 생긴다(2026-09-14 실측:
# 네이버로 크롤링해 둔 맛집이 다음 TourAPI 재수집 때마다 끊겼다 붙었다 했다).
# 그래서 TourAPI 소스가 아닌 기존 연결은 항상 kept_ids/personal 에 그대로 얹어 보존한다.
for key, row in existing.items():
if int(getattr(row, "source", LocalSource.TOUR_API.value)) == LocalSource.TOUR_API.value:
continue
kept_ids.add(row.local_content_id)
personal.setdefault(str(row.local_content_id), {
"kind": AREA_KIND.get(int(row.content_type)),
"distanceMeters": row.distance_m,
"hidden": bool(row.hidden),
})
# 이번 응답에 없는 **관계**만 끊는다. 실체는 남긴다 — 다른 업장이 가리키고 있을 수 있다.
_, removed = await DB_SESSION_MNG.execute_lambda_claim(
place_area_refs.DBType(), lambda s: self.place_crud.soft_delete_missing(s, place_id, kept_ids)

View File

@ -0,0 +1,239 @@
"""주변 맛집 보강 — TourAPI 데이터가 중심이고, Perplexity+네이버 크롤링은 부수적인 보강이다.
TourAPI 이미 있는 맛집은 건이든(군산 절골길 18처럼 59건이어도) 그대로 전부 보여준다
모듈은 그중 어떤 것도 지우거나 숨기지 않는다(2026-09-14 사용자 확정). Perplexity 지역검색
상위 10 이름 TourAPI(또는 이전에 이미 크롤링해 ) 없는 이름만 네이버에서 크롤링해
**추가**한다 "상위 10개" Perplexity 검색 후보의 상한일 , 최종 화면에 보이는 개수의
상한이 아니다.
네이버 URL 확보는 `services/external/naver_place_lookup.py`(상호명+지역으로 네이버 자체 검색
place id) 그대로 재사용한다 Perplexity 도메인필터 재검색으로 시도했다가 실측(2026-09-14,
군산시 6 0 성공)에서 명중률이 낮아 기존 모듈로 바꿨다.
docs/DECISIONS.md 1-1 예외 처리. 설계: tmp/superpowers/specs/2026-09-14-nearby-restaurant-naver-enrichment-design.md
탐지 우회는 하지 않는다 막히면 업체만 포기한다.
"""
import re
from datetime import datetime, timezone
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import area_contents, place_area_refs
from common.enums import AREA_KIND, DBWRType, ErrorType, LocalContentStatus, LocalContentType, LocalSource
from common.logger import LOG
from common.utils.geo import haversine_m
from crud.place_content_crud import PlaceContentCRUD
from services.collector.naver_place_adapter import NaverPlaceAdapter
from services.external import naver_place_lookup, perplexity
from services.external.restaurant_discovery import search_region_restaurants
_NORM_STRIP = re.compile(r"[\s,·.\-_'\"()&]")
_BODY_DROP = ("contentid", "content_type", "distance_m", "latitude", "longitude")
def normalize_name(name: str) -> str:
"""비교용 정규화. 공백·구두점·괄호를 지우고 소문자로 낮춘다."""
return _NORM_STRIP.sub("", (name or "")).lower()
def is_same_restaurant(a: str, b: str) -> bool:
"""이름 유사도 판정. 표기 차이와 지점명 접미사("...본점")는 같은 곳으로 본다.
부분 문자열 포함으로 판정한다 완전 일치만 보면 "이든식당" "이든식당 본점"
다른 곳으로 갈려 TourAPI에 이미 있는 곳을 중복으로 다시 크롤링한다.
"""
na, nb = normalize_name(a), normalize_name(b)
if not na or not nb:
return False
return na == nb or na in nb or nb in na
def to_area_content_body(summary: dict) -> dict:
"""NaverPlaceAdapter.fetch_summary() 결과 → tour_api._normalize()와 같은 모양의 dict.
distance_m 항상 None 이다 지역명 검색으로 찾은 업체라 업장 좌표 기준 거리를
모른다. local_content_service body["distance_m"] 직접 접근 규약을 지키려면
자체는 있어야 한다(값만 비운다).
"""
out = {
"contentid": summary["place_id"],
"content_type": LocalContentType.RESTAURANT.value,
"distance_m": None,
"name": summary["name"],
"searchQuery": summary["name"],
}
if summary.get("address"):
out["location"] = summary["address"]
if summary.get("latitude"):
out["latitude"] = summary["latitude"]
if summary.get("longitude"):
out["longitude"] = summary["longitude"]
if summary.get("imageUrl"):
out["imageUrl"] = summary["imageUrl"]
return out
def _as_float(value) -> float | None:
try:
return float(value) if value not in (None, "") else None
except (TypeError, ValueError):
return None
def _distance_to_place(place_coords: tuple[float, float] | None, summary: dict) -> int | None:
"""업장 좌표 ↔ 크롤링한 맛집 좌표 거리(m). 둘 중 하나라도 없으면 None.
외부 호출 없는 순수 계산이다 좌표는 이미 fetch_summary() 같은 응답에서 받아
값이라 계산에 드는 비용은 없다(common.utils.geo.haversine_m 재사용).
"""
if place_coords is None:
return None
lat, lng = _as_float(summary.get("latitude")), _as_float(summary.get("longitude"))
if lat is None or lng is None:
return None
return round(haversine_m(place_coords[0], place_coords[1], lat, lng))
async def _place_coordinates(place_id) -> tuple[float, float] | None:
"""이 place 자신의 좌표. 크롤링한 맛집과의 거리 계산용으로만 쓴다.
순환 import 회피: story_service 모듈로 이어지는 사슬이 있어 지연 import 한다.
"""
from services.local_content_service import LocalContentService
place = await LocalContentService()._load_place(place_id)
if place is None:
return None
lat, lng = _as_float(getattr(place, "latitude", None)), _as_float(getattr(place, "longitude", None))
if lat is None or lng is None:
return None
return lat, lng
async def _sync_site_personalization(place_id, restaurant_refs: list) -> None:
"""`place_area_refs` 기준 맛집 연결 중 사이트 개인화 맵(site_sections.local)에 없는
것만 채운다 이미 있는 (거리·숨김) 건드리지 않는다.
restaurant_refs: [(content_id, distance_m, hidden), ...] 기존 연결 + 이번에 새로
크롤링한 전부. 기존 것이 이미 맵에 있으면 손대지 않고, 없는 (새로 추가한 ,
또는 과거에 갱신 없이 만들어진 ) 채운다 그래서 자연히 자가복구도 된다.
캔버스(스냅샷) `place_area_refs` 아니라 맵만 읽는다(services/snapshot.py::
_site_places). `place_area_refs`에만 쓰고 여기를 채우면, DB에는 들어가도 화면에는
나온다.
순환 import 회피: local_content_service story_service 모듈로 이어지는 사슬이 있어
지연 import 한다(story_service.run_local_sync 관례와 동일).
"""
if not restaurant_refs:
return
from services.local_content_service import LocalContentService
from services.snapshot import _site_places
places_map = dict(await _site_places(place_id))
changed = False
for content_id, distance_m, hidden in restaurant_refs:
key = str(content_id)
if key in places_map:
continue
places_map[key] = {"kind": "restaurant", "distanceMeters": distance_m, "hidden": bool(hidden)}
changed = True
if changed:
await LocalContentService()._write_site_places(place_id, places_map)
async def enrich_place_restaurants(place_id, region_label: str, region_code: str | None = None) -> dict:
"""이 place 의 기존 맛집(TourAPI 등)은 그대로 두고, Perplexity 지역검색 상위 10개 이름 중
아직 없는 곳만 네이버에서 크롤링해 추가한다. 기존 연결을 지우거나 숨기지 않는다.
흐름: Perplexity 지역 맛집 상위 10 이름을 받는다 이름마다 place 이미
연결된 맛집(TourAPI 또는 이전 크롤링분) 유사도 매칭 있으면 건너뛰고(중복 크롤링 방지),
없으면 네이버에서 크롤링해 새로 연결한다. 실패해도 예외를 던지지 않는다.
"""
stats = {"matched": 0, "added": 0, "checked": 0, "skipped": ""}
if not perplexity.is_configured():
stats["skipped"] = "PERPLEXITY_API_KEY 미설정"
return stats
if not (region_label or "").strip():
stats["skipped"] = "region_label 없음"
return stats
names = await search_region_restaurants(region_label)
if not names:
stats["skipped"] = "Perplexity 검색 결과 없음"
return stats
crud = PlaceContentCRUD()
err, rows = await DB_SESSION_MNG.execute_lambda(
place_area_refs.DBType(), DBWRType.DB_READ.value,
lambda s: crud.list_by_place(s, place_id),
)
if err != ErrorType.SUCCESS:
stats["skipped"] = "기존 목록 조회 실패"
return stats
existing = [r for r in (rows or []) if int(r.content_type) == LocalContentType.RESTAURANT.value]
existing_titles = [r.title for r in existing if r.title]
now = datetime.now(timezone.utc)
place_coords = await _place_coordinates(place_id)
new_entries: list = [] # [(content_id, distance_m), ...]
for name in names:
stats["checked"] += 1
if any(is_same_restaurant(name, title) for title in existing_titles):
stats["matched"] += 1
continue
naver_id = await naver_place_lookup.find_place_id(name, region_label)
if not naver_id:
continue
summary = await NaverPlaceAdapter().fetch_summary(naver_place_lookup.place_url(naver_id))
if not summary:
# ★ naver_place_lookup 이 찾은 id가 실제 상세 페이지가 아닐 수 있다(검색 원문에서
# 상호 근처의 다른 숫자를 잘못 집은 경우) — 조용히 넘어가면 왜 스킵됐는지 안 보인다.
LOG.w(f"[restaurant_enrich] '{name}' place={naver_id} 상세 조회 실패 — 포기")
continue
body = to_area_content_body(summary)
content_values = {
"source": LocalSource.NAVER_CRAWL.value,
"external_id": body["contentid"],
"content_type": body["content_type"],
"kind": AREA_KIND.get(body["content_type"]),
"title": body["name"],
"body": {k: v for k, v in body.items() if k not in _BODY_DROP},
"latitude": _as_float(body.get("latitude")),
"longitude": _as_float(body.get("longitude")),
"region_code": region_code,
"status": LocalContentStatus.PUBLISHED.value,
"display_end_at": None,
"collected_at": now,
}
write_err = await DB_SESSION_MNG.execute_lambda_run(
[area_contents.DBType()],
[lambda s, v=content_values: crud.upsert_content(s, v)],
)
if write_err != ErrorType.SUCCESS:
continue
err_i, rows_i = await DB_SESSION_MNG.execute_lambda(
area_contents.DBType(), DBWRType.DB_READ.value,
lambda s, e=body["contentid"]: crud.find_content_id(s, LocalSource.NAVER_CRAWL.value, e),
)
if err_i != ErrorType.SUCCESS or not rows_i:
continue
content_id = rows_i[0]
distance_m = _distance_to_place(place_coords, summary)
await DB_SESSION_MNG.execute_lambda_run(
[place_area_refs.DBType()],
[lambda s, cid=content_id, d=distance_m: crud.upsert_ref(s, place_id, cid, d)],
)
existing_titles.append(name)
new_entries.append((content_id, distance_m))
stats["added"] += 1
LOG.i(f"[restaurant_enrich] place={place_id} '{name}' 네이버 크롤링으로 추가"
f"{f' (거리 {distance_m}m)' if distance_m is not None else ''}")
restaurant_refs = [(r.local_content_id, r.distance_m, r.hidden) for r in existing]
restaurant_refs += [(cid, dist, False) for cid, dist in new_entries]
await _sync_site_personalization(place_id, restaurant_refs)
return stats

View File

@ -0,0 +1,33 @@
"""Prompt contract for regional restaurant name discovery (Perplexity)."""
SYSTEM_PROMPT = (
"너는 지역 맛집 조사원이다. 실제로 영업 중인 음식점 상호명만 나열한다. "
"설명이나 추천 이유는 쓰지 않는다."
)
RESPONSE_SCHEMA = {
"type": "json_schema",
"json_schema": {
"schema": {
"type": "object",
"properties": {
"restaurants": {
"type": "array",
"items": {"type": "string"},
}
},
"required": ["restaurants"],
}
},
}
def build_prompt(region_label: str) -> str:
return "\n".join([
f"지역: {region_label}",
"",
"이 지역에서 실제로 영업 중인 인기 음식점 상호명을 최대 10개 나열하라.",
"- 상호명만 반환하고 주소·메뉴·설명은 쓰지 마라.",
"- 폐업했거나 존재가 확실하지 않은 곳은 제외한다.",
"- 확실한 곳이 10개 미만이면 있는 만큼만 반환한다.",
])

View File

@ -23,6 +23,7 @@ from pathlib import Path
from common.category_schema import get_schema
from common.enums import (
PUBLISHABLE_FACT_STATUSES,
FactStatus,
LinkChannel,
LocalContentType,
@ -31,6 +32,8 @@ from common.enums import (
SourceType,
)
from common.logger import LOG
from services.intro_summary import summarize_intro
from services.stay_guide import nol_stay_guide
# 렌더러가 확인하는 스키마 버전. 모양이 바뀌면 여기와 site-payload.ts 를 같이 올린다.
SCHEMA_VERSION = 1
@ -611,6 +614,10 @@ def _local_place(row: dict, category: str):
value = _text(body.get(key))
if value:
entry[key] = value
# 수집한 소개를 버리면 stay 목업과 달리 실제 발행 카드에는 이름만 남는다.
description = _text(body.get("description")) or _text(body.get("overview"))
if description:
entry["description"] = description
_put_distance(entry, body)
return entry
@ -864,6 +871,10 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict:
"url": url,
"title": _text(_get(row, "title")) or _CHANNEL_TITLE.get(int(channel), "채널"),
"confirmed": _get(row, "confirmed_at") is not None,
**({"stayGuide": nol_stay_guide(url, _get(row, "raw"))}
if category == PlaceCategory.LODGING.value
and _get(row, "confirmed_at") is not None
and int(channel) == LinkChannel.YANOLJA.value else {}),
})
# ── 소개문 ───────────────────────────────────────────
@ -1026,14 +1037,31 @@ def write_payload(payload: dict) -> str:
return str(path)
def emit_payload(place, snapshot: dict, site, version, links) -> str | None:
async def prepare_site_payload(place, snapshot: dict, site, version, links) -> dict:
"""미리보기·발행 공통 보강. 요약은 DB 스냅샷이 아니라 응답/산출물에만 싣는다."""
payload = to_site_payload(place, snapshot, site, version, links)
for fact in payload["facts"]:
if fact["key"] != "intro" or fact["status"] not in {s.value for s in PUBLISHABLE_FACT_STATUSES}:
continue
try:
summary = await summarize_intro(fact["key"], fact["value"])
except Exception as ex:
# 부가 요약 실패 때문에 미리보기·발행까지 막히면 원문도 읽을 수 없게 된다.
LOG.w(f"[payload] 소개 요약 실패(원문 사용): {type(ex).__name__}")
continue
if summary and summary.strip():
fact["summary"] = summary.strip()
return payload
async def emit_payload(place, snapshot: dict, site, version, links) -> str | None:
"""payload 조립 + 파일 쓰기. 실패해도 예외를 밖으로 내보내지 않는다.
발행 자체를 실패시키면 된다 게이트를 통과해 DB 남은 발행 기록은 이미 정확하고,
payload 그것을 화면으로 옮기는 부수 산출물이다. 디스크가 없거나 권한이 없어서
발행이 되돌려지는 나쁘다. 대신 경고 로그로 반드시 드러낸다."""
try:
payload = to_site_payload(place, snapshot, site, version, links)
payload = await prepare_site_payload(place, snapshot, site, version, links)
path = write_payload(payload)
LOG.i(
f"[payload] {payload['site']['slug']}{path} "

View File

@ -163,11 +163,11 @@ class SiteService:
"빌더에서는 보이는데 발행하면 없다" 파서가 아니라 **렌더러**에서 났다.
그래서 미리보기도 payload 하나만 먹는다. 발행이 굽는 것과 같은 함수
(`snapshot.build_snapshot` `site_payload.to_site_payload`) 그대로 거치므로,
(`snapshot.build_snapshot` `site_payload.prepare_site_payload`) 그대로 거치므로,
여기서 갈릴 자리가 없다. 버전은 아직 없으니 0 으로 넘긴다 화면에 쓰인다.
"""
from services.build_service import ensure_site, _load_links
from services.site_payload import to_site_payload
from services.site_payload import prepare_site_payload
from services.snapshot import build_snapshot
err_type, place = await self._load_place(user_info, place_id)
@ -179,7 +179,7 @@ class SiteService:
snapshot = await build_snapshot(place)
# ★ version 은 None 이다. 발행 전이라 버전 행이 없고, payload 의 site.version 은
# 캐시 무효화 키라 미리보기에서는 뜻이 없다(to_site_payload 가 0 으로 떨어뜨린다).
return to_site_payload(place, snapshot, site, None, links)
return await prepare_site_payload(place, snapshot, site, None, links)
# ---- 사이트 주소(네임스페이스) ---------------------------------------
# 규칙(정규식·예약어)은 services/site_slug 한 곳에만 있다. 확인과 저장이 그것을 같이 쓴다.

View File

@ -32,6 +32,7 @@ from common.enums import (
ErrorType,
FactStatus,
LocalContentStatus,
LocalContentType,
LocalSource,
MediaStatus,
PlaceCategory,
@ -43,7 +44,7 @@ from services.external.naver import region_key
_PUBLISHABLE = tuple(s.value for s in PUBLISHABLE_FACT_STATUSES)
# 지역 정보를 종류별로 몇 건까지 박제할지.
# ★ 종류별(맛집·관광지·축제·코스) 노출 상한 — 화면·캔버스·발행본 모두 이 수까지만 보여준다(2026-09-07 결정).
# ★ 관광지·축제·코스 노출 상한. 맛집은 수집된 전체를 발행한다(2026-09-14).
# 스냅샷은 site_versions.snapshot 에 통째로 들어가므로 반경 안 수백 건을 다 박제하면 버전 행마다 복사된다.
# 두 캐시(지역 수기 항목 + 업장 반경)를 **합쳐서** 센다 — 따로 세면 최대 40건이 나간다.
_LOCAL_MAX_PER_TYPE = 20
@ -362,14 +363,15 @@ async def _site_places(place_id) -> dict:
def _local_rows(rows, seen: dict[int, int], source: int | None = None) -> list[dict]:
"""행 → 스냅샷 항목. 종류별 상한(_LOCAL_MAX_PER_TYPE)은 들어온 순서(정렬)대로 자른다.
"""행 → 스냅샷 항목. 맛집 외 종류별 상한은 들어온 순서(정렬)대로 자른다.
seen 호출측이 넘겨 캐시에 걸쳐 누적한다."""
out = []
for row in rows:
content_type = int(row.content_type)
kind = getattr(row, "kind", None)
taken = seen.get(content_type, 0)
if taken >= _LOCAL_MAX_PER_TYPE:
# 맛집 수집은 전체 보존인데 여기서 20개로 자르면 발행본만 일부가 사라진다.
if content_type != LocalContentType.RESTAURANT.value and taken >= _LOCAL_MAX_PER_TYPE:
continue
seen[content_type] = taken + 1
body = row.body if isinstance(row.body, dict) else {}

View File

@ -0,0 +1,77 @@
"""확정된 NOL 링크의 수집 원문에서 공개 안내 섹션만 전달한다."""
import re
from urllib.parse import urlsplit
def nol_stay_guide(url: str, raw) -> dict:
try:
parsed = urlsplit(url)
except ValueError:
return {}
if parsed.scheme != "https" or parsed.hostname != "nol.yanolja.com":
return {}
if not re.fullmatch(r"/stay/domestic/\d+/?", parsed.path):
return {}
text = raw.get("text") if isinstance(raw, dict) else None
if not isinstance(text, str):
return {}
labels = {"시설/서비스": "service", "이용 안내": "policy", "예약 공지": "reservation"}
# 수집기가 붙인 경계로만 나눈다. 규정 안의 괄호나 문장은 분리하지 않는다.
sections = re.split(r"(?m)^\[(숙소 소개|시설/서비스|이용 안내|예약 공지)\]\s*\n", text)
guide = {}
for label, body in zip(sections[1::2], sections[2::2]):
if label not in labels:
continue
lines = body.strip().splitlines()
if lines and lines[0].strip() == label:
lines.pop(0)
# 제목 중복과 UI 버튼만 제외한다. 요약·추론 없이 수집 문장을 보존한다.
body = "\n".join(line for line in lines if line.strip() != "전체보기").strip()
if body:
guide[labels[label]] = body
if guide:
guide["fields"] = structured_fields(guide)
return guide
def structured_fields(guide: dict) -> list[dict]:
"""확실한 표기만 구조화한다. 매칭되지 않은 내용은 안내 원문에 남는다."""
policy = guide.get("policy", "")
service = guide.get("service", "")
reservation = guide.get("reservation", "")
lines = {line.strip().lstrip("- ") for line in (policy + "\n" + reservation).splitlines()}
facilities = {line.strip() for line in service.splitlines()}
fields = []
def add(key, label, value, group="rules", note=None):
fields.append(dict(key=key, label=label, value=value, group=group,
**({"note": note} if note else {})))
for token, key, label in (("체크인", "check_in_time", "체크인 시간"),
("체크아웃", "check_out_time", "체크아웃 시간")):
matches = set(re.findall(rf"{token}\s+([0-2]\d:[0-5]\d)(?!\d)", policy))
if len(matches) == 1:
value = matches.pop()
if int(value[:2]) < 24:
add(key, label, value)
fees = [re.fullmatch(r"전 연령 동일 1인당\s*([\d,]+)\s*(만)?원", line) for line in lines]
amounts = {int(m[1].replace(",", "")) * (10000 if m[2] else 1) for m in fees if m}
if len(amounts) == 1:
add("extra_person_fee", "인원 추가 요금", f"{amounts.pop():,}", note="1인당 · 전 연령 동일")
if "반려동물 입실금지" in lines and not any("반려동물 입실가능" == line for line in lines):
add("pet_allowed", "반려동물 동반", "불가")
if "전 구역 금연" in lines:
add("smoking", "흡연", "불가", "facilities", "전 구역 금연")
for token, key, label in (("주차가능", "parking", "주차"), ("와이파이", "wifi", "와이파이"),
("취사가능", "cooking_allowed", "취사")):
if token in facilities:
restrictions = [line.strip().lstrip("- ") for line in reservation.splitlines()
if "조리금지" in line or "조리 금지" in line]
add(key, label, "가능", "facilities",
"\n".join(restrictions) if key == "cooking_allowed" and restrictions else None)
known = [name for name in ("욕조", "개별 화장실", "주방", "테라스/발코니", "OTT (스트리밍 서비스)",
"다이닝룸", "벽난로", "어메니티", "카페형룸") if name in facilities]
if known:
add("facilities", "부대시설", ", ".join(known), "facilities")
return fields

View File

@ -33,6 +33,7 @@ from crud.local_content_crud import LocalContentCRUD
from services.grounding import story as grounding
from services.external import tour_api, wikimedia
from services.llm import perplexity
from services.local_restaurant_enrichment import enrich_place_restaurants
from services.prompts import story as prompts
# ★ 다섯을 **순차로** 부른다. 처음엔 동시에 띄웠는데 실측(2026-09-09, 전북 군산시)에서
@ -278,6 +279,13 @@ async def run_local_sync(job: dict) -> dict:
if not synced.result.success:
LOG.w(f"[story] place={place_id} 반경 수집 실패(이야기는 계속한다): {synced.msg}")
# ── 1.5 주변 맛집 보강(Perplexity + 네이버) — 업장마다 ─────────────
# ★ TourAPI 블록 바로 뒤다. 그쪽이 이미 만든 place_area_refs 개수를 기준으로
# "10건 미만이면 채운다"를 판단하기 때문이다(services/local_restaurant_enrichment.py).
out["restaurant_enrichment"] = await enrich_place_restaurants(
uuid.UUID(str(place_id)), region_label, region_code,
)
# ── 2. 여행 일정(LLM) — 업장마다 ──────────────────────────────────
# ★ 이야기와 같은 잡에 둔다. 사장님에게는 "주변이 채워졌나" 하나이고, 둘 다 Perplexity 라
# 같은 키로 나간다 — 잡을 나누면 두 잡이 동시에 떠서 서로를 429 로 막는다.

View File

@ -7,6 +7,7 @@
수정 : 사람이 직접 넣으면 즉시 노출값 교체 + 재빌드 대상 표시
"""
from common.enums import ErrorType, FactStatus, FactWriteOutcome, PlaceCategory, SourceType
from services.external import gemini_text as gt
OTA = "https://ota.test/room/1"
@ -320,3 +321,60 @@ async def test_facts_are_scoped_to_owner(auth_headers, client):
h2 = await auth_headers("o2")
r = await client.get(f"/v1/place/{pid}/fact/list", headers=h2)
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
# ── 캔버스 미리보기 요약 ────────────────────────────────────────────────────
async def test_long_intro_gets_ai_summary_in_list(auth_headers, client, monkeypatch):
"""검증: intro 가 요약 임계치(200자)를 넘는다.
기대결과: /fact/list 응답의 summary 축약문이 실리고, value(원문) 그대로 남는다."""
async def _fake_summarize(text, **_kwargs):
return "축약된 소개문입니다."
monkeypatch.setattr(gt, "summarize_text", _fake_summarize)
h = await auth_headers("u1")
pid = await _verified_place(client, h, kakao="sum1")
long_intro = "조용한 숙소입니다. " * 30 # 200자 초과
await _crawl(client, h, pid, "intro", long_intro, SourceType.LLM)
facts = await _facts(client, h, pid)
fact = next(f for f in facts["facts"] if f["key"] == "intro")
assert fact["value"] == long_intro
assert fact["summary"] == "축약된 소개문입니다."
async def test_short_intro_has_no_summary(auth_headers, client, monkeypatch):
"""검증: intro 가 임계치보다 짧다.
기대결과: summary 비어 있고, 요약 API 아예 불리지 않는다."""
calls = []
async def _fake_summarize(text, **_kwargs):
calls.append(text)
return "호출되면 안 된다"
monkeypatch.setattr(gt, "summarize_text", _fake_summarize)
h = await auth_headers("u1")
pid = await _verified_place(client, h, kakao="sum2")
await _crawl(client, h, pid, "intro", "조용한 숙소입니다.", SourceType.LLM)
facts = await _facts(client, h, pid)
fact = next(f for f in facts["facts"] if f["key"] == "intro")
assert fact.get("summary") is None
assert calls == [], "짧은 문장인데 요약 API 를 불렀다"
async def test_non_intro_fact_never_gets_summary(auth_headers, client, monkeypatch):
"""검증: 길어도 intro/room_intro 가 아닌 key(예: cancel_policy).
기대결과: 대상 key 아니므로 summary 만들지 않는다."""
calls = []
async def _fake_summarize(text, **_kwargs):
calls.append(text)
return "호출되면 안 된다"
monkeypatch.setattr(gt, "summarize_text", _fake_summarize)
h = await auth_headers("u1")
pid = await _verified_place(client, h, kakao="sum3")
await _own(client, h, pid, "cancel_policy", "환불 규정 안내입니다. " * 30)
facts = await _facts(client, h, pid)
fact = next(f for f in facts["facts"] if f["key"] == "cancel_policy")
assert fact.get("summary") is None
assert calls == []

View File

@ -361,3 +361,64 @@ def test_ground_check_grounds_numbers_from_unit_summaries():
grounding = FACTS + gt._unit_facts([{"name": "A동", "facts": {"max_capacity": "4"}}])
ok, _reasons = grounding_copy.ground_check("A동은 최대 4명까지 이용하실 수 있습니다.", grounding)
assert ok is True
# ── 요약(summarize_text) ──────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _clear_summary_cache():
"""모듈 전역 캐시가 테스트끼리 새어 들어가지 않게 매번 비운다."""
gt._SUMMARY_CACHE.clear()
async def test_summarize_returns_shortened_text():
"""검증: 긴 문장을 요약 API 로 축약한다.
기대결과: 응답 텍스트가 그대로 반환되고, 원문이 요청 프롬프트에 실린다."""
payload = {
"candidates": [{"content": {"parts": [{"text": "짧게 줄인 문장입니다."}]}, "finishReason": "STOP"}],
"usageMetadata": {"promptTokenCount": 300, "candidatesTokenCount": 20},
}
calls = []
async with _client(_ok(payload, calls)) as c:
result = await gt.summarize_text("첫 번째 테스트용 원문입니다. " * 20, client=c)
assert result == "짧게 줄인 문장입니다."
prompt = json.loads(calls[0].content)["contents"][0]["parts"][0]["text"]
assert "첫 번째 테스트용 원문입니다." in prompt
async def test_summarize_skips_when_not_configured():
"""검증: GEMINI_API_KEY 미설정.
기대결과: 호출 자체를 하고 None 캔버스는 원문으로 폴백한다."""
llm.external_api_config.gemini_api_key = ""
result = await gt.summarize_text("두 번째 테스트용 원문입니다. " * 20)
assert result is None
async def test_summarize_caches_repeated_calls():
"""검증: 같은 원문을 두 번 요약 요청한다.
기대결과: 번째는 API 다시 부르지 않고 캐시된 값을 돌려준다."""
payload = {"candidates": [{"content": {"parts": [{"text": "캐시 확인용 요약"}]}, "finishReason": "STOP"}]}
calls = []
text = "세 번째 테스트용 원문입니다. " * 20
async with _client(_ok(payload, calls)) as c:
first = await gt.summarize_text(text, client=c)
second = await gt.summarize_text(text, client=c)
assert first == second == "캐시 확인용 요약"
assert len(calls) == 1, "같은 원문인데 API 를 두 번 불렀다"
async def test_summarize_returns_none_on_repeated_failure():
"""검증: 재시도까지 전부 5xx 로 실패한다.
기대결과: 예외를 올리지 않고 None 요약 실패가 캔버스를 깨면 된다."""
async with _client(lambda r: httpx.Response(503, text="unavailable")) as c:
result = await gt.summarize_text("네 번째 테스트용 원문입니다. " * 20, client=c, max_retries=1)
assert result is None
async def test_summarize_empty_text_returns_none():
"""검증: 빈 문자열.
기대결과: 호출 없이 None."""
result = await gt.summarize_text(" ")
assert result is None

View File

@ -6,6 +6,7 @@
*/
import type { FactDataUnitId } from './factDataUnitId';
import type { FactDataValue } from './factDataValue';
import type { FactDataSummary } from './factDataSummary';
import type { FactDataUnit } from './factDataUnit';
import type { SourceType } from './sourceType';
import type { FactDataSourceUrl } from './factDataSourceUrl';
@ -21,6 +22,7 @@ export interface FactData {
unit_id?: FactDataUnitId;
key: string;
value?: FactDataValue;
summary?: FactDataSummary;
unit?: FactDataUnit;
source_type: SourceType;
source_url?: FactDataSourceUrl;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type FactDataSummary = string | null;

View File

@ -19,6 +19,7 @@ export * from './factData';
export * from './factDataCollectedAt';
export * from './factDataExpiresAt';
export * from './factDataSourceUrl';
export * from './factDataSummary';
export * from './factDataUnit';
export * from './factDataUnitId';
export * from './factDataValue';

View File

@ -44,6 +44,15 @@ export function introParagraph(_industryId: IndustryType, infoFields: InfoField[
return collected?.value?.trim() ?? '';
}
/**
* . `intro`/`room_intro` fact (·
* DB ). (introParagraph) .
*/
export function introSummary(_industryId: IndustryType, infoFields: InfoField[]): string {
const collected = infoFields.find((f) => f.id === 'intro' || f.id === 'room_intro');
return collected?.summary?.trim() ?? '';
}
/**
* fact . .
*

View File

@ -4,10 +4,13 @@
*/
import {EmptyStateNotice, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {primaryPhoto, introParagraph} from '../common';
import {primaryPhoto, introParagraph, introSummary} from '../common';
export function IntroSideBySide(props: SectionRenderProps) {
const intro = props.section.body?.trim() || introParagraph(props.industryId, props.infoFields);
const intro =
props.section.body?.trim() ||
introSummary(props.industryId, props.infoFields) ||
introParagraph(props.industryId, props.infoFields);
const {section, isSelected, onSelect, storeName, template, photos} = props;
const shot = photos[1] ?? primaryPhoto(photos);
// 사진이 없으면 회색 판만 남기고 <img> 는 렌더하지 않는다.

View File

@ -3,10 +3,13 @@
*/
import {EmptyStateNotice, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {introParagraph} from '../common';
import {introParagraph, introSummary} from '../common';
export function IntroStory(props: SectionRenderProps) {
const intro = props.section.body?.trim() || introParagraph(props.industryId, props.infoFields);
const intro =
props.section.body?.trim() ||
introSummary(props.industryId, props.infoFields) ||
introParagraph(props.industryId, props.infoFields);
const {section, isSelected, onSelect, storeName, template, photos} = props;
const shotUrl = photos[1]?.url;

View File

@ -155,6 +155,8 @@ function factField(fact: FactData, spec?: FieldSpecData): InfoField {
isVerified: publishable,
source: factSource(fact),
critical: spec?.critical,
// 캔버스 미리보기용 축약문(intro/room_intro 가 길 때만 백엔드가 채워 보낸다).
summary: fact.summary?.trim() || undefined,
};
}

View File

@ -15,6 +15,9 @@ export interface InfoField {
id: string;
label: string;
value: string;
/** . intro/room_intro ( ,
* place_facts ). (value) . */
summary?: string;
/** 수집됐지만 사장님 확인이 필요한 값(백엔드 FactStatus.UNVERIFIED / PENDING_OWNER 대응). */
requiresVerification: boolean;
/** [맞아요] 또는 수정 승인을 거쳤는가(VERIFIED / CORRECTED 대응). */

View File

@ -131,6 +131,8 @@ export interface FactEntry {
key: string;
label: string;
value: string | null;
/** 긴 숙소소개를 정보 표에 표시할 축약문. 없으면 value 원문을 표시한다. */
summary?: string;
unit?: string | null;
type: 'text' | 'number' | 'bool' | 'time' | 'date';
scope: 'place' | 'unit';
@ -181,6 +183,13 @@ export interface FaqEntry {
}
export interface ChannelLink {
/** 확정된 NOL 숙소 링크의 수집 안내 원문. 기존 payload는 생략 가능하다. */
stayGuide?: {
fields?: {key: string; label: string; value: string; group: 'rules' | 'facilities'; note?: string}[];
service?: string;
policy?: string;
reservation?: string;
};
channel: LinkChannel;
url: string;
title?: string;
@ -285,6 +294,8 @@ export interface WeatherSnapshot {
export interface LocalPlace {
name: string;
category: string;
/** 수집 출처가 제공한 장소 주소. */
location?: string;
/** 업장 좌표 기준 거리("850m"/"1.2km"). 지역 캐시에서 온 항목엔 없을 수 있다. */
distanceText?: string;
/**

View File

@ -43,6 +43,10 @@
var items = catchphrases();
if (items.length === 0) return;
/* , ** ** (2026-09-11 :
"메인 모토는 고정으로 하고, 그 아래 계속 변동되는 모토가 돌아가는 방향").
예전에는 대표 문구 자리를 통째로 갈아 끼워 "히로쓰 가옥 담 너머…" 5 사라졌다
숙소 이름 다음으로 남아야 줄이 화면에서 지워지는 셈이었다. */
/* , ** ** (2026-09-11 :
"메인 모토는 고정으로 하고, 그 아래 계속 변동되는 모토가 돌아가는 방향").
예전에는 대표 문구 자리를 통째로 갈아 끼워 "히로쓰 가옥 담 너머…" 5 사라졌다
@ -129,6 +133,44 @@
}
return out.filter(Boolean);
}
function ensureNode() {
if (node && node.isConnected) return node;
var tagline = findTagline();
if (!tagline) return null;
node = document.createElement('p');
node.id = 'w4d-sub';
node.className = 'w4d-sub measure';
tagline.insertAdjacentElement('afterend', node);
reserve();
return node;
}
if (!base || !findTagline()) return;
/* ** ** .
숙소 이름과 대표 문구가 통째로 위아래로 튄다. 지금 폭에서 문구를 보고 가장 높은 값을 쓴다. */
function reserve() {
var width = node.getBoundingClientRect().width;
if (!width) return;
var probe = node.cloneNode(false);
probe.removeAttribute('id');
probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;width:' + width + 'px';
node.parentNode.appendChild(probe);
var max = 0;
items.forEach(function (it) {
probe.textContent = it.text;
max = Math.max(max, probe.getBoundingClientRect().height);
});
probe.remove();
node.style.minHeight = Math.ceil(max) + 'px';
}
var resizeTimer = null;
window.addEventListener('resize', function () {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(function () { if (node && node.isConnected) reserve(); }, 200);
});
var cycle = [];
function buildCycle() { return items.filter(function (item) { return item.kind === "general"; }); }
/* . innerHTML
지금은 우리가 문장이지만, 자리는 나중에 서버가 채운다. */
@ -200,7 +242,7 @@
// 대표 문구가 먼저 읽히고 나서 아래 줄이 선다 — 둘이 한꺼번에 뜨면 어느 쪽이 숙소의 한 줄인지 안 읽힌다.
window.setTimeout(function () {
tick();
window.setInterval(tick, 7000);
window.setInterval(function () { if (!document.hidden && !REDUCED) tick(); }, 6000);
}, 1200);
}

View File

@ -1,3 +1,4 @@
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import {useSite} from '@site/lib/site-context';
import {primaryImage} from '@site/seo/meta';
import {
@ -97,7 +98,7 @@ export function Hero() {
<div className="col-span-12 flex flex-col gap-5 px-4 py-8 sm:px-6 xl:col-span-4 xl:col-start-1 xl:row-start-1 xl:py-12 xl:pb-56 xl:pl-10 xl:pr-8">
{tagline && (
<p className="measure font-serif text-[length:var(--fs-lead)] leading-relaxed">
{tagline}
<HeroCatchphrase>{tagline}</HeroCatchphrase>
</p>
)}

View File

@ -1,3 +1,4 @@
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import type {CSSProperties, ReactNode} from 'react';
import {isoDate} from '@site/lib/format';
import {useSite} from '@site/lib/site-context';
@ -326,7 +327,7 @@ export function Shell({children}: {children: ReactNode}) {
)}
{slogan && (
<p className="text-muted measure shrink-0 text-[length:var(--fs-xs)] leading-[1.9] sm:max-w-[18rem] sm:text-right">
{slogan}
<HeroCatchphrase>{slogan}</HeroCatchphrase>
</p>
)}
</div>

View File

@ -1,3 +1,4 @@
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice} from '@site/lib/derive';
@ -87,7 +88,7 @@ export function Hero() {
{line && (
<p className="measure mt-4 text-[length:var(--fs-lead)] font-medium leading-relaxed opacity-80">
{line}
<HeroCatchphrase>{line}</HeroCatchphrase>
</p>
)}

View File

@ -1,3 +1,4 @@
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import {useSite} from '@site/lib/site-context';
import {bookingLabel, bookingLinks, unitSpec} from '@site/lib/derive';
@ -275,7 +276,7 @@ export function Hero() {
// 이름이 다 선 뒤에 한 박자 쉬고 따라온다. 동시에 뜨면 두 줄이 한 덩어리로 보인다.
style={{animationDelay: '0.3s'}}
>
{subline}
<HeroCatchphrase>{subline}</HeroCatchphrase>
</p>
)}

View File

@ -1,3 +1,4 @@
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import type {ReactNode} from 'react';
import {useSite} from '@site/lib/site-context';
import {
@ -83,7 +84,7 @@ export function Shell({children}: {children: ReactNode}) {
{(narrative.tagline ?? narrative.heroSubline) && (
<p className="text-muted mt-3 text-[length:var(--fs-sm)]" style={{lineHeight: 1.7}}>
{narrative.tagline ?? narrative.heroSubline}
<HeroCatchphrase>{narrative.tagline ?? narrative.heroSubline}</HeroCatchphrase>
</p>
)}

View File

@ -28,6 +28,7 @@ export {publicLinks};
*/
export interface InfoRow {
key?: string;
label: string;
value: string;
/** 부가 설명. 출처가 아니라 사장님이 붙인 보충 문구. */
@ -39,6 +40,7 @@ export function essentialRows(payload: SitePayload): InfoRow[] {
return selectPublishable(payload.facts)
.filter((fact) => fact.scope === 'place')
.map((fact) => ({
key: fact.key,
label: displayFactLabel(fact),
value: displayValue(fact, payload.facts),
}))
@ -46,6 +48,8 @@ export function essentialRows(payload: SitePayload): InfoRow[] {
}
function displayValue(fact: FactEntry, all: FactEntry[]): string {
// 두 이용 정보 영역은 같은 소개 요약을 쓴다. 상세 소개와 다른 사실은 원문을 보존한다.
if (fact.key === 'intro' && fact.summary?.trim()) return fact.summary.trim();
const text = factText(all, fact.key);
if (!text) return '';
if (fact.type !== 'bool') return text;

View File

@ -0,0 +1,12 @@
[
"잠시 걸음을 멈추고 온전한 쉼을 마주하는 시간",
"바쁜 일상을 내려놓고 나를 위한 여유를 담아갑니다",
"여행의 길목에서 만나는 조용하고 편안한 머무름",
"아무것도 하지 않아도 편안해지는 오늘의 휴식",
"낯선 곳에서 느끼는 다정한 온기와 작은 쉼표",
"지친 하루의 끝에 찾아오는 나만의 따뜻한 여백",
"천천히 흘러가는 시간 속에서 마주하는 깊은 휴식",
"스쳐 가는 여행 속에서 오래 기억될 아늑한 순간",
"복잡한 생각은 잠시 잊고 편안하게 쉬어가세요",
"작은 쉼이 모여 내일을 살아갈 따뜻한 힘이 됩니다"
]

View File

@ -1,3 +1,4 @@
import {selectPublishable} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {factualSummary} from '@site/seo/meta';
import {essentialRows} from '@site/lib/derive';
@ -17,7 +18,8 @@ import {formatKoreanDate} from '@site/lib/format';
export function AnswerBlock() {
const payload = useSite();
const rows = essentialRows(payload).slice(0, 6);
const summary = payload.narrative.summary ?? factualSummary(payload);
const intro = selectPublishable(payload.facts).find((fact) => fact.key === 'intro');
const summary = intro?.summary?.trim() || payload.narrative.summary || factualSummary(payload);
if (rows.length === 0 && !summary) return null;

View File

@ -1,6 +1,6 @@
import {Phone} from 'lucide-react';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, essentialRows, RULE_FACT_KEYS} from '@site/lib/derive';
import {bookingActionLabel, bookingLinks, essentialRows} from '@site/lib/derive';
import {formatKoreanDate} from '@site/lib/format';
import type {InfoRow} from '@site/lib/derive';
import {Section} from '@site/lib/ui';
@ -43,19 +43,45 @@ const RULE_LABELS = new Set([
export function EssentialInfoSection() {
const payload = useSite();
const rows = essentialRows(payload);
const guides = payload.links.filter((link) => link.confirmed &&
[link.stayGuide?.policy, link.stayGuide?.service, link.stayGuide?.reservation].some((text) => text?.trim()));
const structured = guides.flatMap((link) => link.stayGuide?.fields ?? []);
const mergedRows = [...rows];
const seen = new Set(rows.map((row) => row.key));
for (const field of structured) {
// 직접 입력한 노출값을 우선한다. 같은 항목을 출처마다 반복하지 않는다.
if (!seen.has(field.key)) {
mergedRows.push(field);
seen.add(field.key);
}
}
const isRule = (row: InfoRow) => {
if (['cooking_allowed', 'smoking'].includes(row.key ?? '')) return false;
return RULE_LABELS.has(row.label) || structured.some((field) => field.key === row.key && field.group === 'rules');
};
const hiddenCount = payload.facts.filter(
(fact) => fact.scope === 'place' && !rows.some((row) => row.label === fact.label),
).length;
if (rows.length === 0) return null;
if (rows.length === 0 && guides.length === 0) return null;
/**
* .
*
* . .
*/
const ruleRows = rows.filter((row) => RULE_LABELS.has(row.label));
const otherRows = rows.filter((row) => !RULE_LABELS.has(row.label));
const ruleRows = [
...mergedRows.filter(isRule),
...guides.filter((link) => !link.stayGuide?.fields?.length && link.stayGuide?.policy).map((link) => ({
label: 'NOL 이용 안내', value: link.stayGuide?.policy ?? '',
})),
];
const otherRows = [
...mergedRows.filter((row) => !isRule(row)),
...guides.filter((link) => !link.stayGuide?.fields?.length && link.stayGuide?.service).map((link) => ({
label: 'NOL 시설 안내', value: link.stayGuide?.service ?? '',
})),
];
return (
<Section
@ -71,7 +97,7 @@ export function EssentialInfoSection() {
<span>
{hiddenCount > 0
? `확인 중인 항목 ${hiddenCount}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.`
: '모든 항목이 사업자 확인을 거쳤습니다.'}
: '등록 정보와 수집한 안내를 기준으로 표시합니다.'}
</span>
<span className="font-medium">{formatKoreanDate(payload.site.updatedAt)} </span>
</span>
@ -80,6 +106,11 @@ export function EssentialInfoSection() {
<div className="space-y-8">
{ruleRows.length > 0 && <Rows title="예약 전 확인" rows={ruleRows} emphasis />}
{otherRows.length > 0 && <Rows title="시설 · 편의" rows={otherRows} />}
{guides.filter((link) => link.stayGuide?.reservation).map((link) => (
<Rows key={`reservation-${link.url}`} title="예약 공지" emphasis rows={[
{label: 'NOL 예약 공지', value: link.stayGuide?.reservation ?? ''},
]} />
))}
<BookingRow />
</div>
</Section>
@ -112,16 +143,16 @@ function Rows({title, rows, emphasis}: {title: string; rows: InfoRow[]; emphasis
{/* ★ 테두리는 판 하나에만 준다. 줄 사이는 선(divide)이지 상자가 아니다. */}
<dl className="divide-line divide-y">
{rows.map((row) => (
{rows.map((row, index) => (
<div
key={row.label}
key={`${row.label}-${index}`}
className="flex flex-col gap-1 py-3.5 sm:flex-row sm:items-baseline sm:gap-6"
>
<dt className="text-muted text-[length:var(--fs-sm)] sm:w-40 sm:shrink-0">
{row.label}
</dt>
{/* measure: 긴 규정 문장이 화면 끝까지 늘어나면 다음 줄 첫 글자를 못 찾는다. */}
<dd className="measure text-[length:var(--fs-sm)] font-semibold">
<dd className="measure min-w-0 whitespace-pre-line break-words text-[length:var(--fs-sm)] font-semibold">
{row.value}
{row.note && <span className="text-muted mt-0.5 block font-normal">{row.note}</span>}
</dd>

View File

@ -0,0 +1,34 @@
import {useEffect, useState, type CSSProperties} from 'react';
import {PlaceCategory} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import lines from '@site/lib/lodging-catchphrases.json';
import './hero-catchphrase.css';
export function HeroCatchphrase({children}: {children?: string}) {
const {place} = useSite();
const lodging = place.category === PlaceCategory.LODGING;
const [index, setIndex] = useState(0);
// 업장 소개는 첫 줄에 고정하고, 공통 감성 문구만 둘째 줄에서 순환한다.
const phrases = lines;
useEffect(() => {
if (!lodging) return;
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
const timer = window.setInterval(() => {
if (!document.hidden && !motion.matches) setIndex((value) => (value + 1) % phrases.length);
}, 6000);
return () => window.clearInterval(timer);
}, [lodging, phrases.length]);
if (!lodging) return children;
const text = phrases[index % phrases.length];
return (
<span className="hero-catchphrase">
{children && <span className="hero-catchphrase-fixed">{children}</span>}
<span className="w4d-line w4d-line--in" key={index}>
{text.split(/\s+/).map((word, i) => (
<span key={i}>{i > 0 && ' '}<span className="w4d-w"><span style={{'--w4d-d': `${i * 55}ms`} as CSSProperties}>{word}</span></span></span>
))}
</span>
</span>
);
}

View File

@ -1,3 +1,4 @@
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import {useCallback, useEffect, useRef, useState} from 'react';
import {ChevronLeft, ChevronRight, Phone} from 'lucide-react';
import {useSite} from '@site/lib/site-context';
@ -124,7 +125,7 @@ export function HeroPension() {
</h1>
{(narrative.tagline ?? narrative.heroSubline) && (
<p className="measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-90">
{narrative.tagline ?? narrative.heroSubline}
<HeroCatchphrase>{narrative.tagline ?? narrative.heroSubline}</HeroCatchphrase>
</p>
)}
</div>

View File

@ -1,3 +1,4 @@
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import {ChevronDown, MapPin} from 'lucide-react';
import {useSite} from '@site/lib/site-context';
import {primaryImage} from '@site/seo/meta';
@ -99,7 +100,7 @@ export function HeroSection() {
{/* ★ 한 줄이 없으면 요약이라도 세운다 — 이름만 덩그러니 있는 첫 화면은 안내가 아니다. */}
{(narrative.tagline ?? narrative.heroSubline ?? narrative.summary) && (
<p className="measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-85">
{narrative.tagline ?? narrative.heroSubline ?? narrative.summary}
<HeroCatchphrase>{narrative.tagline ?? narrative.heroSubline ?? narrative.summary}</HeroCatchphrase>
</p>
)}

View File

@ -1,3 +1,4 @@
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import {Phone} from 'lucide-react';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice, placeRow} from '@site/lib/derive';
@ -59,7 +60,7 @@ export function HeroSplit() {
{place.name}
</h1>
<p className="measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-80">
{narrative.tagline ?? narrative.heroSubline ?? narrative.summary}
<HeroCatchphrase>{narrative.tagline ?? narrative.heroSubline ?? narrative.summary}</HeroCatchphrase>
</p>
</div>

View File

@ -47,6 +47,16 @@ function meters(text: string | undefined): number {
return Number(m[1]) * (m[2].toLowerCase() === 'km' ? 1000 : 1);
}
function distanceOf(place: LocalPlace): number {
// 숫자가 없는 옛 payload만 표기 문자열로 복원한다.
if (place.distanceMeters != null) {
return Number.isFinite(place.distanceMeters) && place.distanceMeters >= 0
? place.distanceMeters
: Infinity;
}
return meters(place.distanceText);
}
/**
* .
*
@ -68,11 +78,11 @@ export function LocalGuideSection() {
const all = [...local.restaurants, ...local.attractions];
const count = (r: (typeof RANGES)[number]) =>
all.filter((place) => r.test(meters(place.distanceText))).length;
all.filter((place) => r.test(distanceOf(place))).length;
// 비었거나 전체와 똑같은 탭은 세우지 않는다 — 눌러도 그대로인 탭은 고장으로 읽힌다.
const ranges = RANGES.filter((r, i) => i === 0 || (count(r) > 0 && count(r) < all.length));
const active = ranges.find((r) => r.id === range) ?? RANGES[0];
const within = (place: LocalPlace) => active.test(meters(place.distanceText));
const within = (place: LocalPlace) => active.test(distanceOf(place));
return (
<Section
@ -201,7 +211,7 @@ function PlaceList({
)}
{/* .
, . '분' 'm' . */}
{place.distanceText && (
{(Number.isFinite(distanceOf(place)) || place.distanceText) && (
<span
className="absolute bottom-2 left-2 rounded-md px-2 py-0.5 text-[length:var(--fs-xs)] font-bold"
style={{
@ -209,7 +219,7 @@ function PlaceList({
color: 'var(--tpl-bg, #fff)',
}}
>
{walkText(meters(place.distanceText)) ?? place.distanceText}
{walkText(distanceOf(place)) ?? place.distanceText}
<span className="ml-1 font-normal opacity-70">{place.distanceText}</span>
</span>
)}
@ -217,6 +227,9 @@ function PlaceList({
<span className="flex flex-1 flex-col gap-1 p-3.5">
<span className="text-[length:var(--fs-sm)] font-bold">{place.name}</span>
{place.location && (
<span className="text-muted text-[length:var(--fs-xs)] break-words">{place.location}</span>
)}
{place.description && (
<span className="text-muted line-clamp-3 text-[length:var(--fs-xs)] leading-relaxed">
{place.description}
@ -245,10 +258,11 @@ function PlaceList({
<span>
{' '}
{place.distanceText}
{walkText(meters(place.distanceText)) && ` · ${walkText(meters(place.distanceText))}`}
{walkText(distanceOf(place)) && ` · ${walkText(distanceOf(place))}`}
</span>
)}
{place.description && <p>{place.description}</p>}
{place.location && <p>{place.location}</p>}
</li>
))}
</ul>

View File

@ -0,0 +1,12 @@
.hero-catchphrase { display: block; }
.hero-catchphrase-fixed { display: block; }
.hero-catchphrase .w4d-line { display: block; margin-top: 0.5rem; min-height: 1.5em; word-break: keep-all; }
.hero-catchphrase .w4d-w { display: inline-block; overflow: hidden; vertical-align: bottom; }
.hero-catchphrase .w4d-w > span { display: inline-block; animation: lodging-word-in 0.45s both; animation-delay: var(--w4d-d); }
@keyframes lodging-word-in {
from { opacity: 0; transform: translateY(100%); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.hero-catchphrase .w4d-w > span { animation: none; }
}

View File

@ -0,0 +1,27 @@
import {expect, it} from 'vitest';
import {renderToStaticMarkup} from 'react-dom/server';
import {PlaceCategory} from '@o2o/shared';
import {SiteProvider} from '@site/lib/site-context';
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
import lines from '@site/lib/lodging-catchphrases.json';
import {HeroCatchphrase} from './HeroCatchphrase';
it('keeps the original lodging tagline in the static HTML', () => {
const html = renderToStaticMarkup(<SiteProvider payload={MOONLIGHT_STAY_PAYLOAD}><HeroCatchphrase></HeroCatchphrase></SiteProvider>);
expect(html).toContain('<span class="hero-catchphrase-fixed">고유문구</span>');
expect(html).toContain('w4d-line--in');
const text = html.replace(/<[^>]+>/g, '');
expect(text).toContain('고유문구' + lines[0]);
});
it('does not add lodging copy or controls to another category', () => {
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
payload.place.category = PlaceCategory.CAFE;
const html = renderToStaticMarkup(<SiteProvider payload={payload}><HeroCatchphrase> </HeroCatchphrase></SiteProvider>);
expect(html).toBe('카페 소개');
});
it('ships ten distinct short phrases', () => {
expect(new Set(lines).size).toBe(10);
expect(lines.every((line) => line.length >= 15 && line.length <= 30)).toBe(true);
});

View File

@ -0,0 +1,45 @@
import {expect, it} from 'vitest';
import {renderToStaticMarkup} from 'react-dom/server';
import {FactStatus, SourceType, type SitePayload} from '@o2o/shared';
import {SiteProvider} from '@site/lib/site-context';
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
import {AnswerBlock} from './AnswerBlock';
import {EssentialInfoSection} from './EssentialInfoSection';
import {AboutSection} from './AboutSection';
function fixture(summary?: string): SitePayload {
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
payload.facts = [{
key: 'intro', label: '숙소 소개', value: '숙소 소개 전체 원문입니다.', summary,
scope: 'place', type: 'text', status: FactStatus.VERIFIED,
sourceType: SourceType.LLM, critical: false, required: false,
}];
payload.narrative = {about: ['숙소 소개 전체 원문입니다.'], summary: '기존 첫 문장'};
return payload;
}
it('shows the same summary in both booking information sections', () => {
const payload = fixture('짧게 요약한 숙소 소개');
for (const section of [<AnswerBlock />, <EssentialInfoSection />]) {
const html = renderToStaticMarkup(<SiteProvider payload={payload}>{section}</SiteProvider>);
expect(html).toContain('짧게 요약한 숙소 소개');
expect(html).not.toContain('숙소 소개 전체 원문입니다.');
}
const about = renderToStaticMarkup(<SiteProvider payload={payload}><AboutSection /></SiteProvider>);
expect(about).toContain('숙소 소개 전체 원문입니다.');
});
it.each([undefined, ' '])('keeps original text when summary is %s', (summary) => {
const payload = fixture(summary);
const html = renderToStaticMarkup(<SiteProvider payload={payload}><EssentialInfoSection /></SiteProvider>);
expect(html).toContain('숙소 소개 전체 원문입니다.');
});
it('does not expose summaries from unverified facts', () => {
const payload = fixture('검증 안 된 요약');
payload.facts[0].status = FactStatus.UNVERIFIED;
for (const section of [<AnswerBlock />, <EssentialInfoSection />]) {
const html = renderToStaticMarkup(<SiteProvider payload={payload}>{section}</SiteProvider>);
expect(html).not.toContain('검증 안 된 요약');
}
});

View File

@ -15,7 +15,6 @@ import {useState} from 'react';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {SongsSection} from './SongsSection';
import {DailySection} from './DailySection';
import {PeopleSection} from './PeopleSection';
import {ChronicleSection} from './ChronicleSection';
import {PostcardSection} from './PostcardSection';
@ -30,7 +29,6 @@ const COUNT_WORD: Record<number, string> = {1: '한', 2: '두', 3: '세', 4: '
const TABS = [
{id: 'songs', label: '가요 다방', Component: SongsSection},
{id: 'daily', label: '오늘의 한 장', Component: DailySection},
{id: 'people', label: '인물 열전', Component: PeopleSection},
{id: 'chronicle', label: '시간의 골목', Component: ChronicleSection},
{id: 'postcard', label: '오늘의 엽서', Component: PostcardSection},
@ -38,6 +36,7 @@ const TABS = [
export function StorySection() {
const payload = useSite();
const title = sectionName(payload, 'story', `${payload.place.addressLocality ?? '지역'} 이야기`);
// 데이터가 있는 것만 탭이 된다 — 눌러서 빈 화면을 보게 하지 않는다.
const tabs = TABS.filter((tab) => sectionItems(payload, tab.id).items.length > 0);
const [active, setActive] = useState(0);
@ -65,13 +64,13 @@ export function StorySection() {
>
<div className="shell">
<h2 id="story-heading" className="h2">
{sectionName(payload, 'story', '군산 이야기')}
{title}
</h2>
<p className="measure mt-3 text-[length:var(--fs-sm)] opacity-70">
{COUNT_WORD[tabs.length] ?? `${tabs.length}`} . .
</p>
<div className="mt-6 flex flex-wrap gap-1.5" role="tablist" aria-label="군산 이야기">
<div className="mt-6 flex flex-wrap gap-1.5" role="tablist" aria-label={title}>
{tabs.map((tab, index) => {
const on = index === active;
return (

View File

@ -0,0 +1,21 @@
import {expect, it} from 'vitest';
import {renderToStaticMarkup} from 'react-dom/server';
import {SiteProvider} from '@site/lib/site-context';
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
import {LocalGuideSection} from './LocalGuideSection';
it('renders collected descriptions and addresses with numeric distance filters', () => {
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
payload.local.attractions = [];
payload.local.restaurants = [
{name: 'Cafe A', category: 'cafe', searchQuery: 'Cafe A', location: 'Address A',
description: 'Collected description', distanceMeters: 401, distanceText: '400m'},
{name: 'Cafe B', category: 'cafe', searchQuery: 'Cafe B', distanceMeters: 100},
{name: 'Cafe C', category: 'cafe', searchQuery: 'Cafe C'},
];
const html = renderToStaticMarkup(<SiteProvider payload={payload}><LocalGuideSection /></SiteProvider>);
expect(html).toContain('Collected description');
expect(html).toContain('Address A');
expect(html).toMatch(/걸어서 5분 이내 <span[^>]*>1<\/span>/);
expect(html).toContain('Cafe C');
});

View File

@ -0,0 +1,71 @@
import {expect, it} from 'vitest';
import {renderToStaticMarkup} from 'react-dom/server';
import {sanitizePayloadForPublish, FactStatus, SourceType} from '@o2o/shared';
import {SiteProvider} from '@site/lib/site-context';
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
import {EssentialInfoSection} from './EssentialInfoSection';
function fixture(confirmed = true) {
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
payload.facts = [];
payload.links = [{
channel: 1, title: 'NOL', url: 'https://nol.yanolja.com/stay/domestic/10068088', confirmed,
stayGuide: {
policy: '체크인 15:00 체크아웃 11:00\n전 연령 동일 1인당 2만원',
service: '주방\n욕조',
reservation: '- 반려동물 입실금지\n<script>alert(1)</script>',
},
}];
return payload;
}
it('renders guides without facts, before booking actions, preserving line breaks and escaping HTML', () => {
const payload = fixture();
const html = renderToStaticMarkup(<SiteProvider payload={payload}><EssentialInfoSection /></SiteProvider>);
expect(html).toContain('예약 전 확인');
expect(html).toContain('체크인 15:00 체크아웃 11:00\n전 연령 동일 1인당 2만원');
expect(html).toContain('주방\n욕조');
expect(html).toContain('반려동물 입실금지');
expect(html).toContain('whitespace-pre-line');
expect(html).toContain('&lt;script&gt;');
expect(html).not.toContain('<script>');
expect(html).not.toContain('모든 항목이 사업자 확인');
expect(html.indexOf('반려동물 입실금지')).toBeLessThan(html.indexOf('예약은 아래로'));
});
it('does not render or embed guides from unconfirmed links', () => {
const payload = fixture(false);
const html = renderToStaticMarkup(<SiteProvider payload={payload}><EssentialInfoSection /></SiteProvider>);
expect(html).toBe('');
expect(JSON.stringify(sanitizePayloadForPublish(payload))).not.toContain('반려동물 입실금지');
});
it('supports older payloads without guides', () => {
const payload = fixture();
delete payload.links[0].stayGuide;
expect(renderToStaticMarkup(<SiteProvider payload={payload}><EssentialInfoSection /></SiteProvider>)).toBe('');
});
it('keeps structured rows and restrictions without the original disclosure or source link', () => {
const payload = fixture();
payload.facts = [{key: 'check_in_time', label: '체크인 시간', value: '16:00',
scope: 'place', type: 'time', status: FactStatus.CORRECTED, sourceType: SourceType.OWNER,
critical: true, required: true}];
payload.links[0].stayGuide!.fields = [
{key: 'check_in_time', label: '체크인 시간', value: '15:00', group: 'rules'},
{key: 'extra_person_fee', label: '인원 추가 요금', value: '20,000원', note: '1인당 · 전 연령 동일', group: 'rules'},
{key: 'cooking_allowed', label: '취사', value: '가능', note: '생선구이 조리금지', group: 'facilities'},
];
const html = renderToStaticMarkup(<SiteProvider payload={payload}><EssentialInfoSection /></SiteProvider>);
expect(html.match(/체크인 시간/g)).toHaveLength(1);
expect(html).toContain('16:00');
expect(html).toContain('20,000원');
expect(html).toContain('1인당 · 전 연령 동일');
expect(html).toContain('생선구이 조리금지');
expect(html).not.toContain('<details');
expect(html).not.toContain('NOL 이용안내 · 시설 원문');
expect(html).not.toContain('NOL 안내 원문');
expect(html).not.toContain('체크인 15:00 체크아웃 11:00');
expect(html).toContain('반려동물 입실금지');
expect(html).toContain('예약은 아래로');
});