diff --git a/solution/backend/Dockerfile b/solution/backend/Dockerfile index b6fef95..3e49c19 100644 --- a/solution/backend/Dockerfile +++ b/solution/backend/Dockerfile @@ -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 diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index abc4d1b..a280e2d 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -342,6 +342,7 @@ class LocalSource(CodeEnum): # ★ 지역 이야기 생성분. 출처는 항목 안의 source.url 이고 이 값은 '누가 모았나'다 — # 화면이 "AI 가 모았습니다"를 밝힐 근거이자, 나중에 통째로 다시 돌릴 때의 선택자다. LLM = 5 + NAVER_CRAWL = 6 # 네이버 플레이스 크롤링(주변 맛집 보강). docs/DECISIONS.md 1-1 예외 — 봇탐지 우회 없이 공개 응답만 읽는다 class LocalContentStatus(CodeEnum): diff --git a/solution/backend/crud/place_content_crud.py b/solution/backend/crud/place_content_crud.py index ad501f0..37bd6e1 100644 --- a/solution/backend/crud/place_content_crud.py +++ b/solution/backend/crud/place_content_crud.py @@ -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, diff --git a/solution/backend/requirements.txt b/solution/backend/requirements.txt index d46efb0..831e084 100644 --- a/solution/backend/requirements.txt +++ b/solution/backend/requirements.txt @@ -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 시점에 필요) diff --git a/solution/backend/scripts/apply_crawled_facts.py b/solution/backend/scripts/apply_crawled_facts.py new file mode 100644 index 0000000..97b2123 --- /dev/null +++ b/solution/backend/scripts/apply_crawled_facts.py @@ -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)) diff --git a/solution/backend/scripts/generate_lodging_catchphrases.py b/solution/backend/scripts/generate_lodging_catchphrases.py new file mode 100644 index 0000000..a71a881 --- /dev/null +++ b/solution/backend/scripts/generate_lodging_catchphrases.py @@ -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()) diff --git a/solution/backend/scripts/import_yanolja_test_place.py b/solution/backend/scripts/import_yanolja_test_place.py new file mode 100644 index 0000000..731b2d2 --- /dev/null +++ b/solution/backend/scripts/import_yanolja_test_place.py @@ -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 (dry-run: 매핑만 출력) + python scripts/import_yanolja_test_place.py --place-id --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() diff --git a/solution/backend/scripts/pin_gunsan_hanilok.py b/solution/backend/scripts/pin_gunsan_hanilok.py new file mode 100644 index 0000000..5cbf78d --- /dev/null +++ b/solution/backend/scripts/pin_gunsan_hanilok.py @@ -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)) diff --git a/solution/backend/scripts/verify_yanolja_test_place.py b/solution/backend/scripts/verify_yanolja_test_place.py new file mode 100644 index 0000000..72a3653 --- /dev/null +++ b/solution/backend/scripts/verify_yanolja_test_place.py @@ -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 +""" +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() diff --git a/solution/backend/scripts/yanolja_search_and_crawl.py b/solution/backend/scripts/yanolja_search_and_crawl.py new file mode 100644 index 0000000..7b73882 --- /dev/null +++ b/solution/backend/scripts/yanolja_search_and_crawl.py @@ -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/" + +# 검색결과 카드는 +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 안에서 각 객실 카드의 사진을, 그 카드의

(객실명) 앞에 나오는 + 들로 묶어 [(객실명, [사진 URL, ...]), ...] 순서대로 돌려준다. + + 카드 구조가 "사진 캐러셀 →

객실명

→ 설명" 순이라, 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() diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index 89ced65..d70925b 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -225,7 +225,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 diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index da8ae51..53c18b5 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -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: diff --git a/solution/backend/services/collector/naver_place_adapter.py b/solution/backend/services/collector/naver_place_adapter.py index 3bccef9..433499e 100644 --- a/solution/backend/services/collector/naver_place_adapter.py +++ b/solution/backend/services/collector/naver_place_adapter.py @@ -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 diff --git a/solution/backend/services/collector/registry.py b/solution/backend/services/collector/registry.py index 897830b..a5f320c 100644 --- a/solution/backend/services/collector/registry.py +++ b/solution/backend/services/collector/registry.py @@ -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 diff --git a/solution/backend/services/collector/static_html_adapter.py b/solution/backend/services/collector/static_html_adapter.py index f14b20b..af7db41 100644 --- a/solution/backend/services/collector/static_html_adapter.py +++ b/solution/backend/services/collector/static_html_adapter.py @@ -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", diff --git a/solution/backend/services/collector/yanolja_adapter.py b/solution/backend/services/collector/yanolja_adapter.py new file mode 100644 index 0000000..707aae4 --- /dev/null +++ b/solution/backend/services/collector/yanolja_adapter.py @@ -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) + +# 검색결과 카드는
+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 의 몫이다.""" + 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, + ) diff --git a/solution/backend/services/external/gemini_text.py b/solution/backend/services/external/gemini_text.py index e8383bc..0603261 100644 --- a/solution/backend/services/external/gemini_text.py +++ b/solution/backend/services/external/gemini_text.py @@ -203,6 +203,12 @@ async def generate_copy( # 걸지 않는다 — "사실을 더하지 마라"는 프롬프트 지시로 충분하다. _SUMMARY_CACHE: dict[str, str] = {} _SUMMARY_CACHE_MAX = 500 +_SUMMARY_PROMPT = ( + "다음 숙소 소개에서 핵심 특징 1~2개만 골라 한국어 한 문장, 공백 포함 60~80자로 요약해줘. " + "원문에 없는 사실이나 과장 표현을 추가하지 말고, 선택한 사실의 조건과 부정 표현을 유지해. " + "반복되는 상호명, 인사말, 홍보 수식어는 생략해. " + "요약문만 출력하고 다른 말은 붙이지 마.\n\n" +) async def summarize_text( @@ -223,17 +229,15 @@ async def summarize_text( if not is_configured(): return None - cache_key = hashlib.sha256(stripped.encode("utf-8")).hexdigest() + # 길이 기준을 바꾼 뒤 이전 길이의 요약을 재사용하지 않도록 프롬프트도 키에 넣는다. + 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": ( - "다음 문장을 사실을 더하거나 빼지 말고 한국어 2~3문장, 150자 내외로 " - "간결하게 줄여줘. 요약문만 출력하고 다른 말은 붙이지 마.\n\n" + stripped - ), + "text": _SUMMARY_PROMPT + stripped, }]}], "generationConfig": {"temperature": 0.2}, } diff --git a/solution/backend/services/external/restaurant_discovery.py b/solution/backend/services/external/restaurant_discovery.py new file mode 100644 index 0000000..30736dc --- /dev/null +++ b/solution/backend/services/external/restaurant_discovery.py @@ -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) diff --git a/solution/backend/services/fact_service.py b/solution/backend/services/fact_service.py index af4abbf..1138ce0 100644 --- a/solution/backend/services/fact_service.py +++ b/solution/backend/services/fact_service.py @@ -32,8 +32,8 @@ from router.v1.fact.protocol import ( Res_Fact, Res_FactList, ) -from services.external import gemini_text from services.external.gemini_extract import extract_facts +from services.intro_summary import summarize_intro from services.llm.gemini import GeminiError, GeminiNotConfigured # 사장님이 붙여넣은 원문의 출처 표기. ★ 실제 URL 이 아니라 경로 식별자다 — @@ -41,11 +41,7 @@ from services.llm.gemini import GeminiError, GeminiNotConfigured # '어디서 왔는가' 는 여전히 명확하다: 사장님이 화면에 직접 붙여넣었다. OWNER_PASTE_SOURCE = "owner:paste" -# 캔버스 미리보기 요약 대상. 원문이 이 길이를 넘을 때만 요약을 만든다(짧은 문장까지 돈 쓰지 않는다). -_SUMMARY_FACT_KEYS = {"intro", "room_intro"} -_SUMMARY_THRESHOLD_CHARS = 200 - -# 자동 수집 출처 — 노출값을 직접 바꾸지 못하고 후보로만 들어간다. +# 자동 출처. CRAWL과 LLM 문장은 아래의 즉시 노출 경로를 먼저 거친다. _AUTO_SOURCES = (SourceType.API, SourceType.CRAWL, SourceType.LLM) @@ -53,10 +49,9 @@ class FactService: """fact 기록 + 검증 상태 전이. ── 세 갈래 프로세스 ──────────────────────────────────────────────── - 생성 : 자동 수집 → 후보(UNVERIFIED) → 사람이 승인 → 노출값(VERIFIED) → 사이트 빌드 - 업데이트: 재수집 → 값이 같으면 검증 유지(REFRESHED) - 값이 다르면 **노출값은 그대로 두고** 후보(PENDING_OWNER)로 적재 - → 사람이 승인해야 노출값이 교체된다 + 생성 : 크롤링 → 노출값(VERIFIED), API → 후보 → 사람이 승인 + 업데이트: 같은 값이면 검증 유지(REFRESHED). 다른 CRAWL 값은 바로 교체한다. + API 값이나 사람 입력·정정본과 충돌하는 값은 후보(PENDING_OWNER)로 적재한다. 수정 : 사람이 직접 입력 → 노출값 즉시 교체. 정정(CORRECTED)은 잠금 표시가 붙는다 문장 : LLM 이 쓴 소개문·메타(allow_llm 필드) → 승인 없이 바로 노출값 (사실이 아니라 **이미 승인된 사실로 쓴 문장**이다 — upsert_fact 주석) @@ -68,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 에 있는 것만 """ @@ -149,14 +144,9 @@ class FactService: """intro/room_intro 원문이 길면 캔버스 미리보기용 요약을 얹는다. ★ place_facts 에는 저장하지 않는다 — 이 응답(FactData.summary)에만 실린다. - 원문이 짧으면 API 를 부르지 않는다(_SUMMARY_THRESHOLD_CHARS).""" + 원문이 짧으면 API 를 부르지 않는다(summarize_intro).""" for f in facts: - if f.key not in _SUMMARY_FACT_KEYS: - continue - value = (f.value or "").strip() - if len(value) <= _SUMMARY_THRESHOLD_CHARS: - continue - f.summary = await gemini_text.summarize_text(value) + 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: @@ -256,7 +246,8 @@ class FactService: """fact 를 기록한다. 출처에 따라 경로가 갈린다. 사람(owner) → 노출값을 직접 교체한다(수정 프로세스) - 자동 수집 → 노출값과 같으면 확인 시각만 갱신, 다르면 후보로 적재(업데이트 프로세스) + 크롤링 → 원값을 바로 노출, 직접 입력·정정본과 충돌하면 후보로 보존 + API → 노출값과 같으면 확인 시각만 갱신, 다르면 후보로 적재 """ res = Res_Fact() err_type, place = await self._load_place(user_info, place_id) @@ -298,6 +289,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( @@ -324,7 +326,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) @@ -386,9 +388,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, @@ -399,7 +401,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, @@ -409,6 +411,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: diff --git a/solution/backend/services/grounding/itinerary.py b/solution/backend/services/grounding/itinerary.py index 60c3d3a..ed1b2c8 100644 --- a/solution/backend/services/grounding/itinerary.py +++ b/solution/backend/services/grounding/itinerary.py @@ -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): diff --git a/solution/backend/services/intro_summary.py b/solution/backend/services/intro_summary.py new file mode 100644 index 0000000..eb24caf --- /dev/null +++ b/solution/backend/services/intro_summary.py @@ -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) diff --git a/solution/backend/services/itinerary_llm_service.py b/solution/backend/services/itinerary_llm_service.py index 73b4196..1c7f328 100644 --- a/solution/backend/services/itinerary_llm_service.py +++ b/solution/backend/services/itinerary_llm_service.py @@ -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: diff --git a/solution/backend/services/local_content_service.py b/solution/backend/services/local_content_service.py index 95c1143..e30d3b6 100644 --- a/solution/backend/services/local_content_service.py +++ b/solution/backend/services/local_content_service.py @@ -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) diff --git a/solution/backend/services/local_restaurant_enrichment.py b/solution/backend/services/local_restaurant_enrichment.py new file mode 100644 index 0000000..3e93fac --- /dev/null +++ b/solution/backend/services/local_restaurant_enrichment.py @@ -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 diff --git a/solution/backend/services/prompts/restaurant_search.py b/solution/backend/services/prompts/restaurant_search.py new file mode 100644 index 0000000..e4ae259 --- /dev/null +++ b/solution/backend/services/prompts/restaurant_search.py @@ -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개 미만이면 있는 만큼만 반환한다.", + ]) diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index e5a7932..a15a93a 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -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 {}), }) # ── 소개문 ─────────────────────────────────────────── @@ -1009,14 +1020,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} " diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py index 9197f07..31c8341 100644 --- a/solution/backend/services/site_service.py +++ b/solution/backend/services/site_service.py @@ -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 한 곳에만 있다. 확인과 저장이 그것을 같이 쓴다. diff --git a/solution/backend/services/snapshot.py b/solution/backend/services/snapshot.py index 8eaade2..6e14045 100644 --- a/solution/backend/services/snapshot.py +++ b/solution/backend/services/snapshot.py @@ -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 {} diff --git a/solution/backend/services/stay_guide.py b/solution/backend/services/stay_guide.py new file mode 100644 index 0000000..dd019ea --- /dev/null +++ b/solution/backend/services/stay_guide.py @@ -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 diff --git a/solution/backend/services/story_service.py b/solution/backend/services/story_service.py index ec4e48a..d962835 100644 --- a/solution/backend/services/story_service.py +++ b/solution/backend/services/story_service.py @@ -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 로 막는다. diff --git a/solution/frontend/src/features/builder/canvas/variants/intro/IntroSideBySide.tsx b/solution/frontend/src/features/builder/canvas/variants/intro/IntroSideBySide.tsx index d8336af..f1bbaa1 100644 --- a/solution/frontend/src/features/builder/canvas/variants/intro/IntroSideBySide.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/intro/IntroSideBySide.tsx @@ -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); // 사진이 없으면 회색 판만 남기고 는 렌더하지 않는다. diff --git a/solution/frontend/src/features/builder/canvas/variants/intro/IntroStory.tsx b/solution/frontend/src/features/builder/canvas/variants/intro/IntroStory.tsx index b5d617e..2bb23f3 100644 --- a/solution/frontend/src/features/builder/canvas/variants/intro/IntroStory.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/intro/IntroStory.tsx @@ -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; diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index 05bb1ff..5c83d11 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -116,6 +116,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'; @@ -166,6 +168,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; @@ -270,6 +279,8 @@ export interface WeatherSnapshot { export interface LocalPlace { name: string; category: string; + /** 수집 출처가 제공한 장소 주소. */ + location?: string; /** 업장 좌표 기준 거리("850m"/"1.2km"). 지역 캐시에서 온 항목엔 없을 수 있다. */ distanceText?: string; /** diff --git a/solution/site/scripts/mockup/inject.js b/solution/site/scripts/mockup/inject.js index fbe5a81..1f438e0 100644 --- a/solution/site/scripts/mockup/inject.js +++ b/solution/site/scripts/mockup/inject.js @@ -43,57 +43,62 @@ var items = catchphrases(); if (items.length === 0) return; + /* ★ 대표 문구는 고정이고, 순환은 그 **아래 줄**에서 돈다 (2026-09-11 사장님 지시: + "메인 모토는 고정으로 하고, 그 아래 계속 변동되는 모토가 돌아가는 방향"). + 예전에는 대표 문구 자리를 통째로 갈아 끼워 "히로쓰 가옥 담 너머…" 가 5초 뒤 사라졌다 — + 숙소 이름 다음으로 남아야 할 한 줄이 첫 화면에서 지워지는 셈이었다. */ var base = ((P.narrative && P.narrative.tagline) || '').trim(); var node = null; - var last = null; - /* ★ 노드를 들고 있지 않고 매번 찾는다. + /* ★ 노드를 들고 있지 않고 매번 확인한다. 이 목업은 payload 를 손으로 고친 것이라 구워진 마크업과 어긋나고, React 는 하이드레이션에 - 실패하면 #root 를 **통째로 다시 그린다** — 그때 히어로의

가 새 노드로 바뀐다. - 한 번 잡아 둔 참조로 계속 쓰면 그 순간부터 아무 일도 안 일어나면서 오류도 안 난다. */ - function findNode() { - var wanted = [base]; - if (last) wanted.push(last); + 실패하면 #root 를 **통째로 다시 그린다** — 대표 문구

가 새 노드로 바뀌고 그 아래 붙여 둔 + 줄은 같이 사라진다. 한 번 잡아 둔 참조로 계속 쓰면 그 순간부터 아무 일도 안 일어나면서 오류도 안 난다. */ + function findTagline() { var candidates = document.querySelectorAll('#root p'); for (var i = 0; i < candidates.length; i += 1) { - if (wanted.indexOf(candidates[i].textContent.trim()) >= 0) return candidates[i]; + if (candidates[i].textContent.trim() === base) return candidates[i]; } return null; } - if (!findNode()) return; + 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 byKind = function (kind) { - return items.filter(function (it) { return it.kind === kind; }); - }; - var weather = null; // 못 받아 오면 날씨 문장은 아예 순환에 넣지 않는다. - var bag = []; var cycle = []; - - function nextGeneral() { - if (bag.length === 0) { - bag = byKind('general').slice(); - for (var j = bag.length - 1; j > 0; j -= 1) { - var k = Math.floor(Math.random() * (j + 1)); - var t = bag[j]; bag[j] = bag[k]; bag[k] = t; - } - } - return bag.pop(); - } - - function buildCycle() { - var now = new Date(); - var month = now.getMonth() + 1; - var season = SEASON_OF_MONTH[month - 1]; - var out = [nextGeneral()]; - out.push(pick(byKind('season').filter(function (it) { return it.season === season; }))); - out.push(nextGeneral()); - out.push(pick(byKind('month').filter(function (it) { return it.month === month; }))); - out.push(nextGeneral()); - if (weather) { - out.push(pick(byKind('weather').filter(function (it) { return it.weather === weather; }))); - } - return out.filter(Boolean); - } + function buildCycle() { return items.filter(function (item) { return item.kind === "general"; }); } /* 문장 하나를 단어 칸으로 조립한다. innerHTML 을 안 쓰는 이유는 문구가 데이터라서다 — 지금은 우리가 쓴 문장이지만, 이 자리는 나중에 서버가 채운다. */ @@ -118,63 +123,32 @@ } function show(text) { - last = text; + var fresh = !(node && node.isConnected); + if (!ensureNode()) return; if (REDUCED) { node.textContent = text; return; } var current = node.firstElementChild; if (current && current.classList.contains('w4d-line')) { current.classList.remove('w4d-line--in'); current.classList.add('w4d-line--out'); } + // 새로 붙인 줄은 비어 있으니 나갈 문장이 없다 — 기다리지 않고 바로 세운다. window.setTimeout(function () { - if (!node || !node.isConnected) node = findNode(); - if (!node) return; + if (!ensureNode()) return; paint(text); - }, 300); + }, fresh ? 0 : 300); } function tick() { - if (!node || !node.isConnected) node = findNode(); - if (!node) return; if (cycle.length === 0) cycle = buildCycle(); var next = cycle.shift(); if (next && next.text) show(next.text); } - /* 날씨는 렌더러가 쓰는 그 엔드포인트로 받는다(같은 오리진). 실패하면 조용히 넘어간다 — - 날씨 문장 몇 개 때문에 순환이 멈추면 안 된다. */ - function refreshWeather() { - var place = P.place || {}; - if (!place.regionCode || place.latitude == null) return; - var query = 'region_code=' + encodeURIComponent(place.regionCode) + - '&latitude=' + place.latitude + '&longitude=' + place.longitude; - fetch('/v1/local/weather?' + query) - .then(function (r) { return r.ok ? r.json() : null; }) - .then(function (body) { - if (!body || !body.weather) return; - var code = Number(body.weather.weather_code); - // 렌더러(use-live-weather.ts)와 같은 분류를 쓴다 — 갈리면 화면의 날씨와 문장이 어긋난다. - weather = code === 0 ? '맑음' - : code >= 1 && code <= 3 ? '구름많음' - : code >= 50 && code <= 69 ? '비' - : code >= 70 && code <= 79 ? '눈' : '흐림'; - }) - .catch(function () { /* 구운 값 그대로 둔다 */ }); - } - - refreshWeather(); - window.setInterval(refreshWeather, 10 * 60 * 1000); - // 구워진 줄 높이를 하한으로 박는다 — 문장이 짧아 한 줄이 되면 아래 단추가 위로 튄다. - (function reserve() { - var found = findNode(); - if (!found) return; - var h = found.getBoundingClientRect().height; - if (h > 0) found.style.minHeight = Math.ceil(h) + 'px'; - })(); - // 첫 문장은 구워진 그대로 5초 두고 시작한다 — 들어오자마자 글자가 바뀌면 읽던 것을 놓친다. + // 대표 문구가 먼저 읽히고 나서 아래 줄이 선다 — 둘이 한꺼번에 뜨면 어느 쪽이 숙소의 한 줄인지 안 읽힌다. window.setTimeout(function () { tick(); - window.setInterval(tick, 7000); - }, 5000); + window.setInterval(function () { if (!document.hidden && !REDUCED) tick(); }, 6000); + }, 1200); } /* ══ ② 헤더 안 미니 플레이어 ═══════════════════════════════════════════════ diff --git a/solution/site/scripts/mockup/patch_stay.py b/solution/site/scripts/mockup/patch_stay.py index 28079aa..00397dd 100644 --- a/solution/site/scripts/mockup/patch_stay.py +++ b/solution/site/scripts/mockup/patch_stay.py @@ -14,88 +14,8 @@ from pathlib import Path SP = Path(__file__).parent -# ── 캐치프레이즈 100개 ──────────────────────────────────────────────────────── -# 사실이 아니라 문구다(출처가 붙는 값이 아니다). 근거는 payload 의 시설·위치 값 — -# 적산가옥 두 동 · 히로쓰 가옥 옆 · 마당과 정원 · 창고형 카페 · 매일 세탁하는 침구 · 최대 4인. -GENERAL = [ - "백 년 된 집에서 하룻밤", - "담 너머는 히로쓰 가옥입니다", - "독채 두 동, 마당은 각자 씁니다", - "기와 아래에서 아침을 맞습니다", - "새소리로 눈이 떠지는 집", - "골목이 조용해지는 시간에 도착하세요", - "침구는 매일 새것처럼 나갑니다", - "마당 평상에 앉으면 항구 소리가 들립니다", - "1920년대에 지은 집, 지금 쓰는 집", - "대문을 닫으면 골목이 멀어집니다", - "창고를 고친 카페 공간이 딸려 있습니다", - "밤에는 정원 석등만 켜 둡니다", - "초원사진관까지 걸어서 3분", - "원도심 한가운데, 차는 세워 두세요", - "A동은 일본식, B동은 모던입니다", - "네 사람까지 묶는 독채입니다", - "오후 세 시부터 이 집은 손님 것입니다", - "이 골목에서는 서두를 일이 없습니다", - "백 년 된 기와가 비를 받습니다", - "여행이라기보다 하루의 이사입니다", - "군산에서 가장 조용한 담 안쪽", - "이 집의 시간은 조금 느립니다", - "마당, 정원, 그리고 평상 하나", - "짐을 풀면 골목부터 걸어 보세요", - "창을 열면 옆집 기와가 보입니다", - "담배 냄새가 없는 집입니다", - "이불에서 볕 냄새가 납니다", - "두 동 모두 남의 인기척이 없습니다", - "내항까지 걸어 십 분", - "오래된 것을 고쳐 쓰는 집", - "부엌이 있어 아침은 지어 드셔도 됩니다", - "밤에는 골목 등만 남습니다", - "백 년 된 집이 아직 손님을 받습니다", - "개항장 골목의 끝집입니다", - "문패보다 기와를 보고 찾아오세요", - "걸어서 도는 여행에 알맞은 자리", - "마루에 앉는 시간이 일정의 절반입니다", - "좁은 골목 끝, 대문 하나", - "여기서는 계획을 반쯤 접어도 됩니다", - "하루를 통째로 쓰는 숙소", -] -SEASON = { - "봄": ["정원에 꽃이 먼저 옵니다", "봄에는 대문을 열어 둡니다", "벚꽃 지는 골목을 걸어 오세요", - "마당에 앉는 계절이 시작됩니다", "새소리가 가장 많은 철입니다", "창을 열면 봄이 방까지 들어옵니다"], - "여름": ["처마 그늘이 가장 긴 계절입니다", "마당에 물을 뿌리면 시원해집니다", "여름 밤에는 평상이 제일 좋습니다", - "창고형 카페가 낮에는 서늘합니다", "소나기는 기와가 받아 줍니다", "해가 길어 저녁이 넉넉합니다"], - "가을": ["기와 위로 가을 볕이 마릅니다", "가을 바람이 담을 넘어옵니다", "가을 골목은 해가 빨리 눕습니다", - "창을 열어 두고 자도 되는 밤입니다", "가을에는 평상이 가장 좋은 자리입니다", "마당에 잎이 떨어지는 계절입니다"], - "겨울": ["기와에 눈이 앉으면 골목이 조용해집니다", "겨울 아침 마당은 서리 밭입니다", "이불 속에서 겨울 바람 소리를 듣습니다", - "눈 오면 정원 석등만 켜 둡니다", "항구 바람이 매운 계절입니다", "겨울에는 카페 공간이 가장 아늑합니다"], -} -MONTH = { - 1: ["새해 첫 아침을 백 년 집에서", "겨울 골목이 가장 조용한 달입니다"], - 2: ["아직 바람이 찬 원도심입니다", "겨울 끝을 마당에서 봅니다"], - 3: ["정원에 첫 꽃이 오는 달입니다", "대문을 열어 두기 시작합니다"], - 4: ["벚꽃 지는 골목을 걷는 달입니다", "마당에 앉는 시간이 길어집니다"], - 5: ["새소리가 가장 많은 달입니다", "창을 열어 두고 자는 밤입니다"], - 6: ["처마 그늘이 길어지는 달입니다", "여름 앞의 원도심은 한가합니다"], - 7: ["소나기를 기와가 받는 달입니다", "여름 밤 평상이 제일 좋습니다"], - 8: ["낮에는 카페 공간이 서늘합니다", "해가 길어 저녁이 넉넉한 달입니다"], - 9: ["볕이 마르기 시작하는 달입니다", "가을이 담을 넘어오는 구월입니다"], - 10: ["잎이 마당에 떨어지는 달입니다", "걷기에 가장 좋은 달입니다"], - 11: ["해가 빨리 눕는 골목입니다", "이불이 가장 따뜻한 달입니다"], - 12: ["기와에 눈이 앉는 달입니다", "한 해의 끝을 조용히 보내는 자리"], -} -# 키는 렌더러(use-live-weather.ts)의 분류와 같아야 한다 — 맑음·구름많음·비·눈·흐림. -WEATHER = { - "맑음": ["마당 평상에 앉으면 새소리만 들립니다", "정원 쪽으로 그늘이 길게 눕는 시간입니다", "기와가 마르는 냄새가 납니다"], - "구름많음": ["빛이 부드러운 날입니다", "골목 사진이 제일 잘 나오는 빛입니다"], - "흐림": ["빛이 낮게 깔리는 날입니다", "이런 날은 카페 공간이 가장 아늑합니다"], - "비": ["백 년 된 기와를 타고 내리는 소리가 다릅니다", "카페 공간에서 빗소리를 들으실 수 있습니다", "비 오는 골목에는 사람이 없습니다"], - "눈": ["기와에 눈이 앉으면 골목이 통째로 조용해집니다", "눈 밟는 소리가 담 안에서 크게 들립니다"], -} - -items = [{"text": t, "kind": "general"} for t in GENERAL] -items += [{"text": t, "kind": "season", "season": s} for s, lst in SEASON.items() for t in lst] -items += [{"text": t, "kind": "month", "month": m} for m, lst in MONTH.items() for t in lst] -items += [{"text": t, "kind": "weather", "weather": w} for w, lst in WEATHER.items() for t in lst] +GENERAL = json.loads((SP.parents[1] / "src/lib/lodging-catchphrases.json").read_text(encoding="utf-8")) +items = [{"text": text, "kind": "general"} for text in GENERAL] payload = json.loads((SP / "stay-payload-new.json").read_text(encoding="utf-8")) payload["narrative"]["catchphrases"] = {"version": 1, "items": items} diff --git a/solution/site/src/layouts/editorial/Hero.tsx b/solution/site/src/layouts/editorial/Hero.tsx index 3619dbb..e561b26 100644 --- a/solution/site/src/layouts/editorial/Hero.tsx +++ b/solution/site/src/layouts/editorial/Hero.tsx @@ -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() {

{tagline && (

- {tagline} + {tagline}

)} diff --git a/solution/site/src/layouts/oasi/Shell.tsx b/solution/site/src/layouts/oasi/Shell.tsx index a40d09d..c88e256 100644 --- a/solution/site/src/layouts/oasi/Shell.tsx +++ b/solution/site/src/layouts/oasi/Shell.tsx @@ -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 && (

- {slogan} + {slogan}

)}
diff --git a/solution/site/src/layouts/pastel/Hero.tsx b/solution/site/src/layouts/pastel/Hero.tsx index 5e38d7a..84c3010 100644 --- a/solution/site/src/layouts/pastel/Hero.tsx +++ b/solution/site/src/layouts/pastel/Hero.tsx @@ -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 && (

- {line} + {line}

)} diff --git a/solution/site/src/layouts/reservation/Hero.tsx b/solution/site/src/layouts/reservation/Hero.tsx index 96df825..7dcb2aa 100644 --- a/solution/site/src/layouts/reservation/Hero.tsx +++ b/solution/site/src/layouts/reservation/Hero.tsx @@ -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} + {subline}

)} diff --git a/solution/site/src/layouts/studio/Shell.tsx b/solution/site/src/layouts/studio/Shell.tsx index 583d475..94a610c 100644 --- a/solution/site/src/layouts/studio/Shell.tsx +++ b/solution/site/src/layouts/studio/Shell.tsx @@ -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) && (

- {narrative.tagline ?? narrative.heroSubline} + {narrative.tagline ?? narrative.heroSubline}

)} diff --git a/solution/site/src/lib/derive.ts b/solution/site/src/lib/derive.ts index 44b5510..56acbd2 100644 --- a/solution/site/src/lib/derive.ts +++ b/solution/site/src/lib/derive.ts @@ -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; diff --git a/solution/site/src/lib/lodging-catchphrases.json b/solution/site/src/lib/lodging-catchphrases.json new file mode 100644 index 0000000..3ec33a9 --- /dev/null +++ b/solution/site/src/lib/lodging-catchphrases.json @@ -0,0 +1,12 @@ +[ + "잠시 걸음을 멈추고 온전한 쉼을 마주하는 시간", + "바쁜 일상을 내려놓고 나를 위한 여유를 담아갑니다", + "여행의 길목에서 만나는 조용하고 편안한 머무름", + "아무것도 하지 않아도 편안해지는 오늘의 휴식", + "낯선 곳에서 느끼는 다정한 온기와 작은 쉼표", + "지친 하루의 끝에 찾아오는 나만의 따뜻한 여백", + "천천히 흘러가는 시간 속에서 마주하는 깊은 휴식", + "스쳐 가는 여행 속에서 오래 기억될 아늑한 순간", + "복잡한 생각은 잠시 잊고 편안하게 쉬어가세요", + "작은 쉼이 모여 내일을 살아갈 따뜻한 힘이 됩니다" +] diff --git a/solution/site/src/sections/AnswerBlock.tsx b/solution/site/src/sections/AnswerBlock.tsx index f707642..62c89fd 100644 --- a/solution/site/src/sections/AnswerBlock.tsx +++ b/solution/site/src/sections/AnswerBlock.tsx @@ -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; diff --git a/solution/site/src/sections/EssentialInfoSection.tsx b/solution/site/src/sections/EssentialInfoSection.tsx index c5de383..437e7c7 100644 --- a/solution/site/src/sections/EssentialInfoSection.tsx +++ b/solution/site/src/sections/EssentialInfoSection.tsx @@ -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 (
{hiddenCount > 0 ? `확인 중인 항목 ${hiddenCount}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.` - : '모든 항목이 사업자 확인을 거쳤습니다.'} + : '등록 정보와 수집한 안내를 기준으로 표시합니다.'} {formatKoreanDate(payload.site.updatedAt)} 기준 @@ -80,6 +106,11 @@ export function EssentialInfoSection() {
{ruleRows.length > 0 && } {otherRows.length > 0 && } + {guides.filter((link) => link.stayGuide?.reservation).map((link) => ( + + ))}
@@ -112,16 +143,16 @@ function Rows({title, rows, emphasis}: {title: string; rows: InfoRow[]; emphasis {/* ★ 테두리는 판 하나에만 준다. 줄 사이는 선(divide)이지 상자가 아니다. */}
- {rows.map((row) => ( + {rows.map((row, index) => (
{row.label}
{/* measure: 긴 규정 문장이 화면 끝까지 늘어나면 다음 줄 첫 글자를 못 찾는다. */} -
+
{row.value} {row.note && {row.note}}
diff --git a/solution/site/src/sections/HeroCatchphrase.tsx b/solution/site/src/sections/HeroCatchphrase.tsx new file mode 100644 index 0000000..33a4e9c --- /dev/null +++ b/solution/site/src/sections/HeroCatchphrase.tsx @@ -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 ( + + {children && {children}} + + {text.split(/\s+/).map((word, i) => ( + {i > 0 && ' '}{word} + ))} + + + ); +} diff --git a/solution/site/src/sections/HeroPension.tsx b/solution/site/src/sections/HeroPension.tsx index b3bd620..5a01dcf 100644 --- a/solution/site/src/sections/HeroPension.tsx +++ b/solution/site/src/sections/HeroPension.tsx @@ -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() { {(narrative.tagline ?? narrative.heroSubline) && (

- {narrative.tagline ?? narrative.heroSubline} + {narrative.tagline ?? narrative.heroSubline}

)}
diff --git a/solution/site/src/sections/HeroSection.tsx b/solution/site/src/sections/HeroSection.tsx index 792b9bd..6a13acd 100644 --- a/solution/site/src/sections/HeroSection.tsx +++ b/solution/site/src/sections/HeroSection.tsx @@ -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) && (

- {narrative.tagline ?? narrative.heroSubline ?? narrative.summary} + {narrative.tagline ?? narrative.heroSubline ?? narrative.summary}

)} diff --git a/solution/site/src/sections/HeroSplit.tsx b/solution/site/src/sections/HeroSplit.tsx index f9ec36d..8c61cc0 100644 --- a/solution/site/src/sections/HeroSplit.tsx +++ b/solution/site/src/sections/HeroSplit.tsx @@ -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}

- {narrative.tagline ?? narrative.heroSubline ?? narrative.summary} + {narrative.tagline ?? narrative.heroSubline ?? narrative.summary}

diff --git a/solution/site/src/sections/LocalGuideSection.tsx b/solution/site/src/sections/LocalGuideSection.tsx index da2f525..876d10e 100644 --- a/solution/site/src/sections/LocalGuideSection.tsx +++ b/solution/site/src/sections/LocalGuideSection.tsx @@ -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 (
- {walkText(meters(place.distanceText)) ?? place.distanceText} + {walkText(distanceOf(place)) ?? place.distanceText} {place.distanceText} )} @@ -217,6 +227,9 @@ function PlaceList({ {place.name} + {place.location && ( + {place.location} + )} {place.description && ( {place.description} @@ -245,10 +258,11 @@ function PlaceList({ {' '} {place.distanceText} - {walkText(meters(place.distanceText)) && ` · ${walkText(meters(place.distanceText))}`} + {walkText(distanceOf(place)) && ` · ${walkText(distanceOf(place))}`} )} {place.description &&

{place.description}

} + {place.location &&

{place.location}

} ))} diff --git a/solution/site/src/sections/hero-catchphrase.css b/solution/site/src/sections/hero-catchphrase.css new file mode 100644 index 0000000..2d6b627 --- /dev/null +++ b/solution/site/src/sections/hero-catchphrase.css @@ -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; } +} diff --git a/solution/site/src/sections/hero-catchphrase.test.tsx b/solution/site/src/sections/hero-catchphrase.test.tsx new file mode 100644 index 0000000..c65559e --- /dev/null +++ b/solution/site/src/sections/hero-catchphrase.test.tsx @@ -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(고유문구); + expect(html).toContain('고유문구'); + 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(카페 소개); + 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); +}); diff --git a/solution/site/src/sections/intro-summary.test.tsx b/solution/site/src/sections/intro-summary.test.tsx new file mode 100644 index 0000000..b847b43 --- /dev/null +++ b/solution/site/src/sections/intro-summary.test.tsx @@ -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 [, ]) { + const html = renderToStaticMarkup({section}); + expect(html).toContain('짧게 요약한 숙소 소개'); + expect(html).not.toContain('숙소 소개 전체 원문입니다.'); + } + const about = renderToStaticMarkup(); + expect(about).toContain('숙소 소개 전체 원문입니다.'); +}); + +it.each([undefined, ' '])('keeps original text when summary is %s', (summary) => { + const payload = fixture(summary); + const html = renderToStaticMarkup(); + expect(html).toContain('숙소 소개 전체 원문입니다.'); +}); + +it('does not expose summaries from unverified facts', () => { + const payload = fixture('검증 안 된 요약'); + payload.facts[0].status = FactStatus.UNVERIFIED; + for (const section of [, ]) { + const html = renderToStaticMarkup({section}); + expect(html).not.toContain('검증 안 된 요약'); + } +}); diff --git a/solution/site/src/sections/items/StorySection.tsx b/solution/site/src/sections/items/StorySection.tsx index 4f51e5a..b67fb96 100644 --- a/solution/site/src/sections/items/StorySection.tsx +++ b/solution/site/src/sections/items/StorySection.tsx @@ -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 = {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() { >

- {sectionName(payload, 'story', '군산 이야기')} + {title}

이 도시를 {COUNT_WORD[tabs.length] ?? `${tabs.length}`} 갈래로 봅니다. 하나씩 골라 보세요.

-
+
{tabs.map((tab, index) => { const on = index === active; return ( diff --git a/solution/site/src/sections/local-guide.test.tsx b/solution/site/src/sections/local-guide.test.tsx new file mode 100644 index 0000000..594924f --- /dev/null +++ b/solution/site/src/sections/local-guide.test.tsx @@ -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(); + expect(html).toContain('Collected description'); + expect(html).toContain('Address A'); + expect(html).toMatch(/걸어서 5분 이내 ]*>1<\/span>/); + expect(html).toContain('Cafe C'); +}); diff --git a/solution/site/src/sections/stay-guide.test.tsx b/solution/site/src/sections/stay-guide.test.tsx new file mode 100644 index 0000000..c89bba0 --- /dev/null +++ b/solution/site/src/sections/stay-guide.test.tsx @@ -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', + }, + }]; + return payload; +} + +it('renders guides without facts, before booking actions, preserving line breaks and escaping HTML', () => { + const payload = fixture(); + const html = renderToStaticMarkup(); + 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('<script>'); + expect(html).not.toContain('