Compare commits
5 Commits
c07e2bddf4
...
a093921b62
| Author | SHA1 | Date | |
|---|---|---|---|
| a093921b62 | |||
| 67a1db3cb9 | |||
| afd41b425a | |||
| 8920e9b9d0 | |||
| 3342981bd2 |
@ -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
|
||||
|
||||
@ -342,6 +342,7 @@ class LocalSource(CodeEnum):
|
||||
# ★ 지역 이야기 생성분. 출처는 항목 안의 source.url 이고 이 값은 '누가 모았나'다 —
|
||||
# 화면이 "AI 가 모았습니다"를 밝힐 근거이자, 나중에 통째로 다시 돌릴 때의 선택자다.
|
||||
LLM = 5
|
||||
NAVER_CRAWL = 6 # 네이버 플레이스 크롤링(주변 맛집 보강). docs/DECISIONS.md 1-1 예외 — 봇탐지 우회 없이 공개 응답만 읽는다
|
||||
|
||||
|
||||
class LocalContentStatus(CodeEnum):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 시점에 필요)
|
||||
|
||||
@ -75,6 +75,9 @@ class FactData(WebPacketProtocol):
|
||||
unit_id: Optional[uuid.UUID] = None
|
||||
key: str
|
||||
value: Optional[str] = None
|
||||
# ★ 캔버스 미리보기용 축약문. intro/room_intro 원문이 길 때만 채운다 — DB 에는 없다(응답 전용,
|
||||
# FactService._attach_summaries 가 요청마다 계산해 붙인다).
|
||||
summary: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
source_type: SourceType
|
||||
source_url: Optional[str] = None
|
||||
|
||||
73
solution/backend/scripts/apply_crawled_facts.py
Normal file
73
solution/backend/scripts/apply_crawled_facts.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""기존 크롤링 후보를 현재 기록 정책으로 다시 적용한다. --apply 없이는 조회만 한다."""
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from sqlalchemy import select
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import place_facts, places
|
||||
from common.enums import DBWRType, ErrorType, FactStatus, SourceType
|
||||
from common.models.gmodel import UserInfo
|
||||
from crud.fact_crud import FactCRUD
|
||||
from crud.place_crud import PlaceCRUD
|
||||
from router.v1.fact.protocol import Req_UpsertFact
|
||||
from services.fact_service import FactService
|
||||
|
||||
|
||||
async def main(place_id, apply):
|
||||
try:
|
||||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
places.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: DB_SESSION_MNG.execute(s, select(places).where(
|
||||
places.place_id == place_id, places.deleted.is_(False))),
|
||||
)
|
||||
if err != ErrorType.SUCCESS or len(rows or []) != 1:
|
||||
raise RuntimeError('사업장 조회 실패')
|
||||
place = rows[0]
|
||||
err, facts = await DB_SESSION_MNG.execute_lambda(
|
||||
place_facts.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: DB_SESSION_MNG.execute(s, select(place_facts).where(
|
||||
place_facts.place_id == place_id, place_facts.deleted.is_(False),
|
||||
place_facts.source_type == SourceType.CRAWL.value,
|
||||
place_facts.status.in_([FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value]),
|
||||
).order_by(place_facts.collected_at.desc())),
|
||||
)
|
||||
if err != ErrorType.SUCCESS:
|
||||
raise RuntimeError('크롤링 후보 조회 실패')
|
||||
service = FactService(FactCRUD(), PlaceCRUD())
|
||||
actor = UserInfo(user_id=str(place.owner_user_id), id='crawl-policy', role=1)
|
||||
seen = set()
|
||||
result = []
|
||||
for fact in facts or []:
|
||||
key = (fact.unit_id, fact.key)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
row = {'unitId': str(fact.unit_id), 'key': fact.key, 'value': fact.value}
|
||||
if apply:
|
||||
res = await service.upsert_fact(actor, str(place_id), Req_UpsertFact(
|
||||
key=fact.key, value=fact.value, unit_id=fact.unit_id,
|
||||
source_type=SourceType.CRAWL, source_url=fact.source_url,
|
||||
expires_at=fact.expires_at,
|
||||
))
|
||||
if not res.result.success:
|
||||
raise RuntimeError(f'{fact.key}: {res.result.desc}')
|
||||
row['status'] = res.fact.status
|
||||
result.append(row)
|
||||
print(json.dumps({'applied': apply, 'place': place.name, 'facts': result},
|
||||
ensure_ascii=False, default=str))
|
||||
finally:
|
||||
await DB_SESSION_MNG.dispose_all()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--place-id', required=True, type=uuid.UUID)
|
||||
parser.add_argument('--apply', action='store_true')
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(args.place_id, args.apply))
|
||||
42
solution/backend/scripts/generate_lodging_catchphrases.py
Normal file
42
solution/backend/scripts/generate_lodging_catchphrases.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""숙박 공통 감성 문구를 한 번 생성한다. 방문 시에는 저장된 문구만 순환한다."""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from services.llm.gemini import DEFAULT_MODEL, call, extract_text
|
||||
|
||||
PROMPT = """여러 숙박업체 홈페이지 첫 화면에서 돌아가며 보여 줄 한국어 감성 문구 10개를 작성하세요.
|
||||
호텔, 모텔, 펜션, 게스트하우스 어디에나 쓸 수 있어야 합니다.
|
||||
휴식, 여행, 머무는 시간, 일상에서의 잠깐의 쉼을 담아 담백하고 따뜻하게 쓰세요.
|
||||
각 문구는 공백 포함 15~30자. 과장, 진부한 최상급, 상호명, 지명, 계절, 날씨,
|
||||
바다/숲/정원/독채 등 시설이나 경관, 청결/조식/가격 등 확인이 필요한 사실과 서비스 약속은 금지합니다.
|
||||
서로 다른 표현으로 정확히 10개, 문자열 JSON 배열만 반환하세요."""
|
||||
|
||||
|
||||
async def main():
|
||||
async with httpx.AsyncClient(timeout=90) as client:
|
||||
response = await call(client, DEFAULT_MODEL, {
|
||||
"contents": [{"role": "user", "parts": [{"text": PROMPT}]}],
|
||||
"generationConfig": {
|
||||
"responseMimeType": "application/json",
|
||||
"responseSchema": {"type": "ARRAY", "items": {"type": "STRING"}, "minItems": 10, "maxItems": 10},
|
||||
"temperature": 0.8,
|
||||
},
|
||||
}, max_retries=0)
|
||||
lines = json.loads(extract_text(response))
|
||||
if not isinstance(lines, list) or len(lines) != 10 or not all(isinstance(s, str) and 15 <= len(s.strip()) <= 30 for s in lines):
|
||||
raise ValueError("문구 개수·길이 검증 실패")
|
||||
lines = [s.strip() for s in lines]
|
||||
if len(set(lines)) != 10:
|
||||
raise ValueError("중복 문구")
|
||||
target = Path(__file__).resolve().parents[2] / "site/src/lib/lodging-catchphrases.json"
|
||||
target.write_text(json.dumps(lines, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"model": DEFAULT_MODEL, "items": lines}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
186
solution/backend/scripts/import_yanolja_test_place.py
Normal file
186
solution/backend/scripts/import_yanolja_test_place.py
Normal file
@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
야놀자 크롤링 결과를 테스트 place 하나에 적재하는 1회성 스크립트.
|
||||
=================================================================
|
||||
|
||||
★ 운영 파이프라인이 아니다 — services/collector/registry.py 에 등록하지 않고,
|
||||
worker 잡큐에도 연결하지 않는다. 개발자가 수동으로 실행하는 CLI 전용.
|
||||
(registry.py·static_html_adapter.py 의 야놀자 관련 구조적 차단은 그대로 유효하다 —
|
||||
이 스크립트는 그 결론을 뒤집지 않는다. 테스트 목적 한정, 실제 배포 전 별도 협의 필요.)
|
||||
|
||||
동작
|
||||
1. place_id 로 places 테이블에서 업장을 조회한다. 주소는 따로 입력받지 않고
|
||||
그 업장의 road_address(없으면 address)를 그대로 검색어로 쓴다.
|
||||
2. verified_at 이 비어 있으면 중단한다(운영 run_collect 와 같은 가드 —
|
||||
동일 업소 검증 전에는 수집하지 않는다).
|
||||
3. scripts/yanolja_search_and_crawl.py 로 그 주소를 검색·크롤링한다.
|
||||
4. 결과를 CollectedFact/CollectedMedia 로 매핑해서, 기존 collect_service.py 의
|
||||
ensure_units/store_facts/store_media 를 그대로 호출한다 — FactService 를 그대로
|
||||
통과하므로 새 값은 UNVERIFIED/PENDING_OWNER/PENDING_REVIEW 로만 만들어진다.
|
||||
이 상태를 이 스크립트가 직접 VERIFIED/APPROVED 로 바꾸는 일은 없다.
|
||||
|
||||
사용법 (solution/backend 에서, 가상환경 안에서)
|
||||
python scripts/import_yanolja_test_place.py --place-id <UUID> (dry-run: 매핑만 출력)
|
||||
python scripts/import_yanolja_test_place.py --place-id <UUID> --commit (실제 DB 적재)
|
||||
|
||||
PGSSLMODE=disable DB_PASSWORD=... 를 dev-env-quirks 메모대로 주입해야 한다(APP_ENV=local 기본).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
# scripts/ 는 solution/backend 바로 아래이므로, backend 자체(부모 디렉터리)를
|
||||
# sys.path 에 넣어야 common/services 등을 import 할 수 있다.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
os.environ.setdefault("APP_ENV", "local")
|
||||
|
||||
from playwright.sync_api import sync_playwright # noqa: E402
|
||||
from sqlalchemy import select # noqa: E402
|
||||
|
||||
from scripts.yanolja_search_and_crawl import StayData, search_and_crawl # noqa: E402
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402
|
||||
from common.database.model.models import places # noqa: E402
|
||||
from common.enums import DBWRType, ErrorType # noqa: E402
|
||||
from services.collector.base import CollectedFact, CollectedMedia, RawSource # noqa: E402
|
||||
from services.collect_service import ensure_units, store_facts, store_media # noqa: E402
|
||||
|
||||
|
||||
def _parse_capacity(capacity: str | None) -> tuple[str | None, str | None]:
|
||||
"""'기준 2인 / 최대 4인' → ('2', '4')."""
|
||||
if not capacity:
|
||||
return None, None
|
||||
std_m = re.search(r"기준\s*(\d+)인", capacity)
|
||||
max_m = re.search(r"최대\s*(\d+)인", capacity)
|
||||
return (std_m.group(1) if std_m else None, max_m.group(1) if max_m else None)
|
||||
|
||||
|
||||
def stay_data_to_source(data: StayData) -> RawSource:
|
||||
"""StayData → RawSource(facts/media). lodging.json 의 scope=unit 필드 중
|
||||
크롤러가 실제로 채울 수 있는 것만 매핑한다(room_type/standard_capacity/max_capacity 는
|
||||
required=true 라 반드시 채운다. weekday_price 등 요금은 크롤러가 안 모으므로 비워둔다).
|
||||
"""
|
||||
facts: list[CollectedFact] = []
|
||||
media: list[CollectedMedia] = []
|
||||
|
||||
if data.overview.strip():
|
||||
facts.append(CollectedFact(key="intro", value=data.overview.strip(), scope="place"))
|
||||
for url in data.photos:
|
||||
media.append(CollectedMedia(origin_url=url, label="갤러리"))
|
||||
|
||||
for room in data.rooms:
|
||||
std_cap, max_cap = _parse_capacity(room.capacity)
|
||||
facts.append(CollectedFact(key="room_type", value=room.name, scope="unit", unit_name=room.name))
|
||||
if std_cap:
|
||||
facts.append(CollectedFact(key="standard_capacity", value=std_cap, scope="unit", unit_name=room.name))
|
||||
if max_cap:
|
||||
facts.append(CollectedFact(key="max_capacity", value=max_cap, scope="unit", unit_name=room.name))
|
||||
if room.bed:
|
||||
facts.append(CollectedFact(key="bed_type", value=room.bed, scope="unit", unit_name=room.name))
|
||||
if room.description:
|
||||
facts.append(CollectedFact(key="room_intro", value=room.description, scope="unit", unit_name=room.name))
|
||||
for url in room.images:
|
||||
media.append(CollectedMedia(origin_url=url, label="객실 사진", unit_name=room.name))
|
||||
|
||||
return RawSource(url=data.url, adapter_id="yanolja_test_import", facts=facts, media=media)
|
||||
|
||||
|
||||
def _print_preview(source: RawSource) -> None:
|
||||
print(f"\n[미리보기] {source.url}")
|
||||
place_facts = [f for f in source.facts if f.scope == "place"]
|
||||
print(f" place-scope fact {len(place_facts)}건: {[f.key for f in place_facts]}")
|
||||
for unit_name in source.unit_names():
|
||||
unit_facts = [f for f in source.facts if f.unit_name == unit_name]
|
||||
unit_media = [m for m in source.media if m.unit_name == unit_name]
|
||||
print(f" [{unit_name}] fact {len(unit_facts)}건, 사진 {len(unit_media)}장")
|
||||
for f in unit_facts:
|
||||
print(f" {f.key} = {f.value}")
|
||||
place_media = [m for m in source.media if m.unit_name is None]
|
||||
print(f" place-scope 사진 {len(place_media)}장")
|
||||
|
||||
|
||||
async def _load_place(place_id: str):
|
||||
stmt = select(places).where(places.place_id == uuid.UUID(place_id), places.deleted == False) # noqa: E712
|
||||
err, row = await DB_SESSION_MNG.execute_lambda(
|
||||
places.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: DB_SESSION_MNG.execute(s, stmt),
|
||||
)
|
||||
if err != ErrorType.SUCCESS or not row:
|
||||
return None
|
||||
return row[0]
|
||||
|
||||
|
||||
def _crawl_sync(address: str, headful: bool) -> StayData:
|
||||
"""동기 Playwright API는 asyncio 루프가 도는 스레드에서 못 쓴다 — 별도 스레드에서 실행한다."""
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=not headful)
|
||||
context = browser.new_context(
|
||||
locale="ko-KR",
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
||||
),
|
||||
)
|
||||
page = context.new_page()
|
||||
try:
|
||||
return search_and_crawl(page, address)
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
||||
async def run(place_id: str, commit: bool, headful: bool) -> None:
|
||||
place = await _load_place(place_id)
|
||||
if place is None:
|
||||
raise SystemExit(f"place를 찾을 수 없습니다: {place_id}")
|
||||
if place.verified_at is None:
|
||||
raise SystemExit(
|
||||
"동일 업소 검증(verify) 전에는 수집하지 않습니다 — 운영 collect_service.run_collect 와 같은 가드."
|
||||
)
|
||||
|
||||
address = place.road_address or place.address
|
||||
if not address:
|
||||
raise SystemExit("이 place에는 주소가 없습니다.")
|
||||
|
||||
print(f"[대상] {place.name} ({place_id}) — 검색 주소: {address}")
|
||||
|
||||
data = await asyncio.to_thread(_crawl_sync, address, headful)
|
||||
|
||||
print(f"[크롤링 완료] {data.name} — 객실 {len(data.rooms)}개, 갤러리 사진 {len(data.photos)}장")
|
||||
source = stay_data_to_source(data)
|
||||
_print_preview(source)
|
||||
|
||||
if not commit:
|
||||
print("\n(dry-run) DB에는 쓰지 않았습니다. 실제로 저장하려면 --commit 을 주세요.")
|
||||
await DB_SESSION_MNG.dispose_all()
|
||||
return
|
||||
|
||||
from common.models.gmodel import UserInfo
|
||||
|
||||
actor = UserInfo(user_id=str(place.owner_user_id), id="yanolja_test_import", role=1)
|
||||
|
||||
unit_map = await ensure_units(place_id, [source])
|
||||
facts_stat = await store_facts(actor, place_id, [source], unit_map)
|
||||
media_stat = await store_media(place_id, [source], unit_map)
|
||||
|
||||
print(f"\n[적재 완료] unit {len(unit_map)}개, fact={facts_stat}, media={media_stat}")
|
||||
await DB_SESSION_MNG.dispose_all()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="야놀자 크롤링 → 테스트 place 객실 데이터 임포트(1회성)")
|
||||
parser.add_argument("--place-id", required=True, help="대상 place_id(UUID)")
|
||||
parser.add_argument("--commit", action="store_true", help="실제로 DB에 적재(기본은 dry-run)")
|
||||
parser.add_argument("--headful", action="store_true", help="브라우저 창을 보이게 실행(디버깅용)")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(run(args.place_id, args.commit, args.headful))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
94
solution/backend/scripts/pin_gunsan_hanilok.py
Normal file
94
solution/backend/scripts/pin_gunsan_hanilok.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""군산 공통 맛집 한일옥 등록. 기본은 조회, --apply로 현재 설정 DB에 반영한다.
|
||||
|
||||
external_id가 없는 지역 공통 항목은 snapshot이 모든 군산 업장에 포함한다.
|
||||
네이버 ID는 body에 보관하여 자동 수집의 (source, external_id) 갱신과 분리한다.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.engine import URL
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import area_contents, places
|
||||
from common.enums import LocalContentStatus, LocalContentType, LocalSource
|
||||
from config.server_configs import main_db_config as cfg
|
||||
from services.site_payload import _local
|
||||
from services.snapshot import _local_contents
|
||||
|
||||
REGION = '52군산시'
|
||||
NAVER_ID = '11861452'
|
||||
CONTENT_ID = uuid.uuid5(uuid.NAMESPACE_URL, 'web4ai:52군산시:restaurant:11861452')
|
||||
|
||||
|
||||
async def main(apply: bool):
|
||||
engine = create_async_engine(URL.create(
|
||||
'postgresql+asyncpg', username=cfg.write_id, password=cfg.write_pw,
|
||||
host=cfg.write_host, port=cfg.write_port, database=cfg.name,
|
||||
))
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
rows = (await conn.execute(select(
|
||||
area_contents.local_content_id, area_contents.title, area_contents.body,
|
||||
).where(
|
||||
area_contents.region_code == REGION,
|
||||
area_contents.kind == 'restaurant', area_contents.external_id.is_(None),
|
||||
area_contents.deleted.is_(False),
|
||||
))).mappings().all()
|
||||
if rows and any(r['body'].get('naverPlaceId') != NAVER_ID for r in rows):
|
||||
raise RuntimeError('다른 군산 공통 맛집이 이미 있어 덮어쓰지 않습니다.')
|
||||
content_id = rows[0]['local_content_id'] if rows else CONTENT_ID
|
||||
now = datetime.now(timezone.utc)
|
||||
values = dict(
|
||||
local_content_id=content_id, region_code=REGION,
|
||||
content_type=LocalContentType.RESTAURANT.value, kind='restaurant',
|
||||
# 사용자 확인을 거친 수기 웹 등록. 크롤링한 값으로 표시하지 않는다.
|
||||
source=LocalSource.OFFICIAL_WEB.value, external_id=None, title='한일옥',
|
||||
body={**(rows[0]['body'] if rows else {}),
|
||||
'name': '한일옥', 'searchQuery': '군산 한일옥',
|
||||
'naverPlaceId': NAVER_ID,
|
||||
'sourceUrl': f'https://m.place.naver.com/restaurant/{NAVER_ID}/home',
|
||||
'registration': 'owner_confirmed_region_default'},
|
||||
status=LocalContentStatus.PUBLISHED.value, published_at=now,
|
||||
collected_at=now, display_start_at=None, display_end_at=None,
|
||||
expires_at=None, deleted=False, updated_at=now,
|
||||
)
|
||||
if apply:
|
||||
await conn.execute(insert(area_contents).values(**values).on_conflict_do_update(
|
||||
index_elements=[area_contents.local_content_id],
|
||||
set_={k: v for k, v in values.items() if k != 'local_content_id'},
|
||||
))
|
||||
targets = (await conn.execute(select(places.place_id, places.name).where(
|
||||
places.deleted.is_(False), places.region_code == REGION,
|
||||
))).mappings().all()
|
||||
print(json.dumps({'applied': apply, 'database': cfg.name, 'region': REGION,
|
||||
'contentId': str(content_id), 'naverPlaceId': NAVER_ID,
|
||||
'existingPlaces': [dict(r) for r in targets]},
|
||||
default=str, ensure_ascii=False))
|
||||
|
||||
if apply:
|
||||
# place_id 없는 새 군산 업장도 지역 공통 경로만으로 받는지 확인한다.
|
||||
snapshot = await _local_contents(SimpleNamespace(region_code=REGION))
|
||||
local, _ = _local(snapshot, None, None)
|
||||
matches = [r for r in local['restaurants'] if r['name'] == '한일옥']
|
||||
assert len(matches) == 1, '지역 공통 payload에 한일옥이 정확히 한 번 있어야 합니다.'
|
||||
print(json.dumps({'verifiedRegionalPayload': matches}, ensure_ascii=False))
|
||||
finally:
|
||||
await engine.dispose()
|
||||
await DB_SESSION_MNG.dispose_all()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--apply', action='store_true')
|
||||
asyncio.run(main(parser.parse_args().apply))
|
||||
142
solution/backend/scripts/verify_yanolja_test_place.py
Normal file
142
solution/backend/scripts/verify_yanolja_test_place.py
Normal file
@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
테스트 place에 야놀자 크롤링으로 넣은 객실 fact/사진을 **수동으로 검수·승인**해서,
|
||||
실제 미리보기 렌더러(site_payload.to_site_payload → UnitsSection)에 뜨는지 확인하는
|
||||
1회성 스크립트.
|
||||
|
||||
★ 이것도 운영 파이프라인이 아니다 — 사람이 눈으로 확인하려고 이번 테스트 place 하나에만
|
||||
쓰는 수동 검수 도구다. 실제 서비스에서 크롤링 값을 이렇게 자동 승인하면 안 된다
|
||||
(fact는 사람이 [맞아요]를 눌러야, 사진은 Vision/사람이 봐야 승인 상태가 된다).
|
||||
|
||||
사용법 (solution/backend 에서, 가상환경 안):
|
||||
PGSSLMODE=disable DB_PASSWORD=... python scripts/verify_yanolja_test_place.py --place-id <UUID>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
os.environ.setdefault("APP_ENV", "local")
|
||||
|
||||
from sqlalchemy import select # noqa: E402
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402
|
||||
from common.database.model.models import places, place_facts, place_photos # noqa: E402
|
||||
from common.enums import DBWRType, ErrorType, FactStatus, MediaStatus # noqa: E402
|
||||
from common.models.gmodel import UserInfo # noqa: E402
|
||||
from common.utils.gtime import GTime # noqa: E402
|
||||
from crud.fact_crud import FactCRUD # noqa: E402
|
||||
from crud.media_crud import MediaCRUD # noqa: E402
|
||||
from crud.place_crud import PlaceCRUD # noqa: E402
|
||||
from crud.site_crud import SiteCRUD # noqa: E402
|
||||
from crud.job_crud import JobQueue # noqa: E402
|
||||
from router.v1.fact.protocol import Req_TransitionFact # noqa: E402
|
||||
from services.fact_service import FactService # noqa: E402
|
||||
from services.site_service import SiteService # noqa: E402
|
||||
|
||||
# 검수 전 상태(우리가 이번에 넣은 값들)만 건드린다.
|
||||
_PENDING_FACT_STATUSES = (FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value)
|
||||
|
||||
|
||||
async def _load_place(place_id: str):
|
||||
stmt = select(places).where(places.place_id == uuid.UUID(place_id), places.deleted == False) # noqa: E712
|
||||
err, row = await DB_SESSION_MNG.execute_lambda(
|
||||
places.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, stmt)
|
||||
)
|
||||
if err != ErrorType.SUCCESS or not row:
|
||||
return None
|
||||
return row[0]
|
||||
|
||||
|
||||
async def _unit_scope_facts(place_id: str):
|
||||
stmt = select(place_facts).where(
|
||||
place_facts.place_id == uuid.UUID(place_id),
|
||||
place_facts.deleted == False, # noqa: E712
|
||||
place_facts.unit_id.is_not(None),
|
||||
place_facts.status.in_(_PENDING_FACT_STATUSES),
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
place_facts.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, stmt)
|
||||
)
|
||||
return rows if err == ErrorType.SUCCESS else []
|
||||
|
||||
|
||||
async def _unit_scope_pending_photos(place_id: str):
|
||||
stmt = select(place_photos).where(
|
||||
place_photos.place_id == uuid.UUID(place_id),
|
||||
place_photos.deleted == False, # noqa: E712
|
||||
place_photos.unit_id.is_not(None),
|
||||
place_photos.status == MediaStatus.PENDING_REVIEW.value,
|
||||
)
|
||||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
place_photos.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, stmt)
|
||||
)
|
||||
return rows if err == ErrorType.SUCCESS else []
|
||||
|
||||
|
||||
async def run(place_id: str) -> None:
|
||||
place = await _load_place(place_id)
|
||||
if place is None:
|
||||
raise SystemExit(f"place를 찾을 수 없습니다: {place_id}")
|
||||
|
||||
actor = UserInfo(user_id=str(place.owner_user_id), id="yanolja_test_verify", role=1)
|
||||
fact_service = FactService(FactCRUD(), PlaceCRUD())
|
||||
media_crud = MediaCRUD()
|
||||
|
||||
facts = await _unit_scope_facts(place_id)
|
||||
print(f"[검수 대상 fact] {len(facts)}건")
|
||||
verified = 0
|
||||
for f in facts:
|
||||
res = await fact_service.transition(
|
||||
actor, place_id, str(f.fact_id), Req_TransitionFact(status=FactStatus.VERIFIED)
|
||||
)
|
||||
if res.result.success:
|
||||
verified += 1
|
||||
else:
|
||||
print(f" ! {f.key}({f.fact_id}) 전이 실패: {res.result.desc}")
|
||||
print(f" -> VERIFIED 처리 {verified}/{len(facts)}건")
|
||||
|
||||
photos = await _unit_scope_pending_photos(place_id)
|
||||
print(f"[승인 대상 사진] {len(photos)}건")
|
||||
approved = 0
|
||||
now = GTime.UTC()
|
||||
for p in photos:
|
||||
alt_text = p.label or "객실 사진"
|
||||
run_err, _rowcount = await DB_SESSION_MNG.execute_lambda_claim(
|
||||
place_photos.DBType(),
|
||||
lambda s, m=p.media_id, a=alt_text: media_crud.apply_vision(
|
||||
s, m, p.label, a, None, MediaStatus.APPROVED.value, now
|
||||
),
|
||||
)
|
||||
if run_err == ErrorType.SUCCESS:
|
||||
approved += 1
|
||||
print(f" -> APPROVED 처리 {approved}/{len(photos)}건 (alt_text 채움)")
|
||||
|
||||
site_service = SiteService(SiteCRUD(), PlaceCRUD(), JobQueue())
|
||||
payload = await site_service.preview_payload(actor, place_id)
|
||||
units = (payload or {}).get("units", [])
|
||||
print(f"\n[/preview 재확인] units {len(units)}개")
|
||||
for u in units:
|
||||
print(f" - {u.get('name')}: fact {len(u.get('facts', []))}개, media {len(u.get('mediaIds', []))}장")
|
||||
if not units:
|
||||
print(" (여전히 비어 있습니다 — payload 구조나 상태값을 다시 확인하세요)")
|
||||
print(json.dumps(payload, ensure_ascii=False, default=str)[:2000])
|
||||
|
||||
await DB_SESSION_MNG.dispose_all()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="테스트 place의 야놀자 객실 데이터를 검수·승인해서 미리보기에 반영")
|
||||
parser.add_argument("--place-id", required=True)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(run(args.place_id))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
397
solution/backend/scripts/yanolja_search_and_crawl.py
Normal file
397
solution/backend/scripts/yanolja_search_and_crawl.py
Normal file
@ -0,0 +1,397 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
야놀자(NOL) 주소 검색 + 숙소 상세페이지 크롤러
|
||||
==========================================
|
||||
|
||||
★ 운영 collector 파이프라인(services/collector/registry.py)에 등록되지 않은 수동 도구다.
|
||||
registry.py·static_html_adapter.py 에 야놀자를 구조적으로 막아둔 이유(HTTP 403 회피
|
||||
목적의 봇 탐지 우회 금지, 재게시 관련 민사 판례)는 그대로 유효하다. 이 스크립트는
|
||||
개발자가 필요할 때 수동으로만 실행하는 진단·백필용 도구이며, worker/collect_service
|
||||
에서 자동으로 호출되지 않는다.
|
||||
|
||||
동작 순서
|
||||
1. https://nol.yanolja.com/ 접속
|
||||
2. 검색창에 주소를 입력하고 검색 실행
|
||||
3. 검색결과 리스트에서 첫번째 업체(숙소)의 상세페이지 href 를 읽어 바로 이동
|
||||
4. 이동한 상세페이지에서 객실/숙소소개/시설·서비스/이용안내/예약공지 + 객실별 사진을 크롤링
|
||||
|
||||
사전 준비
|
||||
pip install playwright
|
||||
playwright install chromium
|
||||
|
||||
사용법 (solution/backend 에서)
|
||||
python scripts/yanolja_search_and_crawl.py "전북특별자치도 군산시 절골길 18" -o result.json
|
||||
python scripts/yanolja_search_and_crawl.py "전북특별자치도 군산시 절골길 18" --print
|
||||
python scripts/yanolja_search_and_crawl.py "전북특별자치도 군산시 절골길 18" --headful
|
||||
|
||||
주의
|
||||
- 검색 페이지, 상세페이지 모두 Next.js 기반 CSR 페이지라서 Playwright로
|
||||
실제 브라우저를 띄워 렌더링을 끝낸 뒤 DOM에서 데이터를 추출한다.
|
||||
- 페이지 구조(클래스명 등)는 야놀자 쪽에서 언제든 바뀔 수 있다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from playwright.sync_api import Page, TimeoutError as PWTimeoutError, sync_playwright
|
||||
|
||||
SEARCH_URL = "https://nol.yanolja.com/"
|
||||
|
||||
# 검색결과 카드는 <a data-card-type="basic" ... aria-label="{업체명} 상품 상세 보기" href="...">
|
||||
RESULT_CARD_SELECTOR = 'a[data-card-type="basic"]'
|
||||
|
||||
# 각 탭이 스크롤/렌더링되는 실제 섹션 id (2026-09 기준 확인됨)
|
||||
SECTION_IDS = {
|
||||
"rooms": "PLACE_SECTION", # 객실선택
|
||||
"overview": "OVERVIEW_SECTION", # 숙소소개
|
||||
"service": "SERVICE_SECTION", # 시설/서비스
|
||||
"policy": "POLICY_SECTION", # 이용안내
|
||||
"reservation": "RESERVATION_SECTION", # 예약공지
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoomInfo:
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
capacity: Optional[str] = None # 예: "기준 2인 / 최대 4인"
|
||||
bed: Optional[str] = None # 예: "킹 침대 1개"
|
||||
raw_text: str = ""
|
||||
images: list[str] = field(default_factory=list) # 객실 사진 URL(아이콘 제외)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StayData:
|
||||
url: str
|
||||
stay_id: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
search_address: Optional[str] = None
|
||||
picked_name: Optional[str] = None
|
||||
rooms: list[RoomInfo] = field(default_factory=list)
|
||||
overview: str = "" # 숙소 소개
|
||||
service: str = "" # 시설/서비스
|
||||
policy: str = "" # 이용 안내
|
||||
reservation: str = "" # 예약 공지
|
||||
photos: list[str] = field(default_factory=list) # 숙소소개 갤러리 사진 URL(있으면)
|
||||
|
||||
|
||||
def search_and_pick_first(page: Page, address: str, timeout_ms: int = 15000) -> tuple[str, Optional[str]]:
|
||||
"""주소로 검색한 뒤, 첫번째 검색결과 카드의 href를 추출해서 상세페이지로 이동한다.
|
||||
|
||||
카드를 클릭하지 않고 href를 직접 읽어 page.goto()로 이동한다. 클릭 시
|
||||
새 탭이 뜨거나 배너/모달에 클릭이 가로채이는 문제를 피하기 위함이다.
|
||||
"""
|
||||
page.goto(SEARCH_URL, wait_until="domcontentloaded")
|
||||
|
||||
search_box = page.get_by_role("combobox", name="검색어 입력")
|
||||
search_box.click()
|
||||
search_box.fill(address)
|
||||
search_box.press("Enter")
|
||||
|
||||
try:
|
||||
first_card = page.locator(RESULT_CARD_SELECTOR).first
|
||||
first_card.wait_for(state="visible", timeout=timeout_ms)
|
||||
except PWTimeoutError:
|
||||
raise RuntimeError(f"'{address}' 검색결과를 찾지 못했습니다.")
|
||||
|
||||
name = first_card.get_attribute("aria-label") or first_card.inner_text()
|
||||
detail_url = first_card.get_attribute("href")
|
||||
if not detail_url:
|
||||
raise RuntimeError("검색결과 카드에서 href를 찾지 못했습니다.")
|
||||
|
||||
page.goto(detail_url, wait_until="domcontentloaded")
|
||||
return page.url, name # page.url: href가 상대경로여도 절대 URL로 해석된 값
|
||||
|
||||
|
||||
def _extract_stay_id(url: str) -> Optional[str]:
|
||||
m = re.search(r"/stay/domestic/(\d+)", url)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _scroll_through_page(page: Page) -> None:
|
||||
"""지연 로딩(lazy render) 섹션들이 모두 렌더링되도록 페이지를 끝까지 스크롤."""
|
||||
prev_height = -1
|
||||
for _ in range(30):
|
||||
page.mouse.wheel(0, 1200)
|
||||
page.wait_for_timeout(250)
|
||||
cur_height = page.evaluate("document.body.scrollHeight")
|
||||
if cur_height == prev_height:
|
||||
break
|
||||
prev_height = cur_height
|
||||
page.evaluate("window.scrollTo(0, 0)")
|
||||
|
||||
|
||||
def _get_section_text(page: Page, element_id: str) -> str:
|
||||
try:
|
||||
page.evaluate(
|
||||
f"""() => {{
|
||||
const el = document.getElementById({json.dumps(element_id)});
|
||||
if (el) el.scrollIntoView({{block: 'center'}});
|
||||
}}"""
|
||||
)
|
||||
page.wait_for_timeout(400)
|
||||
return page.evaluate(
|
||||
f"""() => {{
|
||||
const el = document.getElementById({json.dumps(element_id)});
|
||||
return el ? el.innerText : '';
|
||||
}}"""
|
||||
) or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
# img src 중 실제 사진(/v5/.../*.jpg 형태)만 남기고 UI 아이콘(static/images/... 의 침대·와이파이
|
||||
# 아이콘, no-image 플레이스홀더 등)은 제외한다.
|
||||
def _is_real_photo_url(src: str) -> bool:
|
||||
return bool(src) and "static/images" not in src
|
||||
|
||||
|
||||
def _get_room_images_by_name(page: Page, element_id: str) -> list[tuple[str, list[str]]]:
|
||||
"""PLACE_SECTION 안에서 각 객실 카드의 사진을, 그 카드의 <h2>(객실명) 앞에 나오는
|
||||
<img> 들로 묶어 [(객실명, [사진 URL, ...]), ...] 순서대로 돌려준다.
|
||||
|
||||
카드 구조가 "사진 캐러셀 → <h2>객실명</h2> → 설명" 순이라, h2 를 만나기 전까지
|
||||
쌓인 이미지가 그 h2 의 몫이다.
|
||||
"""
|
||||
try:
|
||||
page.evaluate(
|
||||
f"""() => {{
|
||||
const el = document.getElementById({json.dumps(element_id)});
|
||||
if (el) el.scrollIntoView({{block: 'center'}});
|
||||
}}"""
|
||||
)
|
||||
page.wait_for_timeout(400)
|
||||
raw = page.evaluate(
|
||||
f"""() => {{
|
||||
const el = document.getElementById({json.dumps(element_id)});
|
||||
if (!el) return [];
|
||||
const nodes = el.querySelectorAll('img, h2');
|
||||
const result = [];
|
||||
let buf = [];
|
||||
for (const n of nodes) {{
|
||||
if (n.tagName === 'IMG') {{
|
||||
if (n.src) buf.push(n.src);
|
||||
}} else if (n.tagName === 'H2') {{
|
||||
result.push({{name: n.textContent.trim(), images: [...new Set(buf)]}});
|
||||
buf = [];
|
||||
}}
|
||||
}}
|
||||
return result;
|
||||
}}"""
|
||||
) or []
|
||||
except Exception:
|
||||
return []
|
||||
return [
|
||||
(item["name"], [u for u in item["images"] if _is_real_photo_url(u)])
|
||||
for item in raw
|
||||
]
|
||||
|
||||
|
||||
def _get_section_images(page: Page, element_id: str) -> list[str]:
|
||||
"""섹션 안의 사진 URL을 순서대로(중복 제거, 아이콘 제외)."""
|
||||
try:
|
||||
page.evaluate(
|
||||
f"""() => {{
|
||||
const el = document.getElementById({json.dumps(element_id)});
|
||||
if (el) el.scrollIntoView({{block: 'center'}});
|
||||
}}"""
|
||||
)
|
||||
page.wait_for_timeout(400)
|
||||
srcs = page.evaluate(
|
||||
f"""() => {{
|
||||
const el = document.getElementById({json.dumps(element_id)});
|
||||
if (!el) return [];
|
||||
return [...new Set(Array.from(el.querySelectorAll('img')).map(i => i.src).filter(Boolean))];
|
||||
}}"""
|
||||
) or []
|
||||
except Exception:
|
||||
return []
|
||||
return [u for u in srcs if _is_real_photo_url(u)]
|
||||
|
||||
|
||||
def _get_stay_name(page: Page) -> Optional[str]:
|
||||
try:
|
||||
h1 = page.query_selector("h1")
|
||||
return h1.inner_text().strip() if h1 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_rooms_from_section_text(section_text: str) -> list[RoomInfo]:
|
||||
"""
|
||||
PLACE_SECTION.innerText 는 대략 아래 패턴이 객실 카드 수만큼 반복됩니다:
|
||||
|
||||
1
|
||||
/
|
||||
10
|
||||
B동
|
||||
(모던한 현대식 컨셉으로 꾸며진 따뜻한 공간)
|
||||
기준 2인 / 최대 4인
|
||||
킹 침대 1개
|
||||
숙박
|
||||
체크인
|
||||
15:00
|
||||
~ 체크아웃
|
||||
11:00
|
||||
취소 및 환불 불가
|
||||
상세보기
|
||||
198,000
|
||||
원
|
||||
NOL 머니 결제 시 최대 3,960P 적립
|
||||
예약하기
|
||||
|
||||
우리가 필요한 건 이름/설명/기준·최대인원/침대 뿐이므로 가격 이하는 무시한다.
|
||||
사이트 구조가 바뀌면 이 정규식도 함께 손봐야 한다.
|
||||
"""
|
||||
rooms: list[RoomInfo] = []
|
||||
# "N / M" (사진 장수) 로 카드 시작 지점을 나눈다
|
||||
chunks = re.split(r"\n?\d+\s*\n/\n\d+\n", section_text)
|
||||
for chunk in chunks[1:]: # 첫 chunk는 "객실 선택" 타이틀 등 헤더
|
||||
lines = [l.strip() for l in chunk.split("\n") if l.strip()]
|
||||
if not lines:
|
||||
continue
|
||||
name = lines[0]
|
||||
capacity_m = re.search(r"기준\s*\d+인\s*/\s*최대\s*\d+인", chunk)
|
||||
bed_m = re.search(r"(킹|퀸|더블|싱글|트윈)\s*침대\s*\d+개", chunk)
|
||||
# 이름과 "기준 N인" 줄 사이의 줄들을 설명으로 간주
|
||||
desc_lines = []
|
||||
for l in lines[1:]:
|
||||
if l.startswith("기준") or "체크인" in l or l == "숙박":
|
||||
break
|
||||
desc_lines.append(l.strip("()"))
|
||||
rooms.append(
|
||||
RoomInfo(
|
||||
name=name,
|
||||
description=" ".join(desc_lines) if desc_lines else None,
|
||||
capacity=capacity_m.group(0) if capacity_m else None,
|
||||
bed=bed_m.group(0) if bed_m else None,
|
||||
raw_text=chunk.strip(),
|
||||
)
|
||||
)
|
||||
return rooms
|
||||
|
||||
|
||||
def crawl_stay(page: Page, url: str) -> StayData:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
try:
|
||||
page.wait_for_selector("h1", timeout=15000)
|
||||
except PWTimeoutError:
|
||||
pass
|
||||
|
||||
_scroll_through_page(page)
|
||||
|
||||
data = StayData(url=url, stay_id=_extract_stay_id(url))
|
||||
data.name = _get_stay_name(page)
|
||||
|
||||
rooms_text = _get_section_text(page, SECTION_IDS["rooms"])
|
||||
data.rooms = _parse_rooms_from_section_text(rooms_text)
|
||||
|
||||
# 카드 순서대로 이미지를 묶어서 뽑은 뒤, 순서가 맞아떨어지면 그대로 붙이고
|
||||
# (카드 수가 어긋나면 사이트 구조가 달라진 것이니) 이름이 같은 것끼리 다시 맞춘다.
|
||||
room_images = _get_room_images_by_name(page, SECTION_IDS["rooms"])
|
||||
if len(room_images) == len(data.rooms):
|
||||
for room, (_, images) in zip(data.rooms, room_images):
|
||||
room.images = images
|
||||
else:
|
||||
images_by_name = {name: images for name, images in room_images}
|
||||
for room in data.rooms:
|
||||
room.images = images_by_name.get(room.name, [])
|
||||
|
||||
data.overview = _get_section_text(page, SECTION_IDS["overview"])
|
||||
data.photos = _get_section_images(page, SECTION_IDS["overview"])
|
||||
data.service = _get_section_text(page, SECTION_IDS["service"])
|
||||
data.policy = _get_section_text(page, SECTION_IDS["policy"])
|
||||
data.reservation = _get_section_text(page, SECTION_IDS["reservation"])
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def search_and_crawl(page: Page, address: str) -> StayData:
|
||||
detail_url, picked_name = search_and_pick_first(page, address)
|
||||
data = crawl_stay(page, detail_url)
|
||||
data.search_address = address
|
||||
data.picked_name = picked_name
|
||||
return data
|
||||
|
||||
|
||||
def _print_stay(data: StayData) -> None:
|
||||
print(f"\n===== {data.name} ({data.url}) =====")
|
||||
if data.search_address:
|
||||
print(f"검색 주소: {data.search_address}")
|
||||
if data.picked_name:
|
||||
print(f"검색결과 선택: {data.picked_name}")
|
||||
print("\n[객실 선택]")
|
||||
for r in data.rooms:
|
||||
print(f" - {r.name}")
|
||||
if r.description:
|
||||
print(f" 설명: {r.description}")
|
||||
if r.capacity:
|
||||
print(f" 인원: {r.capacity}")
|
||||
if r.bed:
|
||||
print(f" 침대: {r.bed}")
|
||||
if r.images:
|
||||
print(f" 사진: {len(r.images)}장 (예: {r.images[0]})")
|
||||
if data.photos:
|
||||
print(f"\n갤러리 사진: {len(data.photos)}장")
|
||||
for label, key in [("숙소 소개", "overview"), ("시설/서비스", "service"),
|
||||
("이용 안내", "policy"), ("예약 공지", "reservation")]:
|
||||
print(f"\n[{label}]")
|
||||
text = getattr(data, key)
|
||||
print(text if text else "(내용 없음)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="야놀자(NOL) 주소 검색 + 숙소 상세페이지 크롤러")
|
||||
parser.add_argument(
|
||||
"address",
|
||||
nargs="?",
|
||||
default="전북특별자치도 군산시 절골길 18",
|
||||
help="검색할 주소 (예: '전북특별자치도 군산시 절골길 18')",
|
||||
)
|
||||
parser.add_argument("-o", "--output", default="yanolja_result.json", help="결과 저장 파일명(JSON)")
|
||||
parser.add_argument("--print", action="store_true", dest="do_print", help="결과를 터미널에도 보기 좋게 출력")
|
||||
parser.add_argument("--headful", action="store_true", help="브라우저 창을 보이게 실행(디버깅용)")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"[검색 시작] 주소: {args.address}", file=sys.stderr)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=not args.headful)
|
||||
context = browser.new_context(
|
||||
locale="ko-KR",
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
||||
),
|
||||
)
|
||||
page = context.new_page()
|
||||
|
||||
try:
|
||||
data = search_and_crawl(page, args.address)
|
||||
except Exception as e:
|
||||
browser.close()
|
||||
print(f"실패: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
browser.close()
|
||||
|
||||
print(f" -> 완료: {data.name} (객실 {len(data.rooms)}개)", file=sys.stderr)
|
||||
|
||||
if args.do_print:
|
||||
_print_stay(data)
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(asdict(data), f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n완료: 결과를 {args.output} 에 저장했습니다.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
328
solution/backend/services/collector/yanolja_adapter.py
Normal file
328
solution/backend/services/collector/yanolja_adapter.py
Normal file
@ -0,0 +1,328 @@
|
||||
"""야놀자(NOL) 국내숙소 어댑터 — Playwright 로 상세페이지를 렌더링해 객실·사진을 수집한다.
|
||||
|
||||
★ nol.yanolja.com 은 Next.js CSR 페이지라 httpx(정적 HTML)로는 못 읽는다 — 그래서
|
||||
static_html_adapter 가 아니라 이 어댑터가 Playwright 로 실제 브라우저 렌더링을 거친다.
|
||||
이건 봇 탐지 우회가 아니라 JS 렌더링이 필요한 페이지를 읽는 통상적인 방법이다 —
|
||||
캡차 우회·IP 회전·지문 위장 같은 건 하지 않는다(registry.py 의 영구 금지 원칙 그대로 유지).
|
||||
차단(403·챌린지 등)을 만나면 그대로 실패로 돌려주고 재시도·우회하지 않는다.
|
||||
|
||||
수집 원칙
|
||||
- 페이지에 보이는 값만 옮긴다. 가격은 수집하지 않는다(불안정하고 예약 시점에 따라 바뀐다).
|
||||
- 이미지는 원본 URL 그대로만 남긴다(origin_url) — 재게시 여부는 발행 게이트가 판단한다.
|
||||
- 객실(unit)별 값은 scope="unit" 로 담는다. 숙소소개·시설/서비스·이용안내·예약공지는
|
||||
전부 크롤링하지만 fact 로 만들지 않는다 — 숙소소개(intro)·객실소개(room_intro)는
|
||||
allow_llm=True 필드라 그대로 넣으면 절대규칙 7을 어기고, 나머지 셋은 스키마의
|
||||
특정 필드와 1:1로 안 맞는다. 넷 다 RawSource.text 로 보존한다. 시설/서비스·이용안내·
|
||||
예약공지는 확정 링크의 payload.links[].stayGuide로도 전달해 원문을 표시한다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from playwright.async_api import Page, TimeoutError as PWTimeoutError, async_playwright
|
||||
|
||||
from common.enums import LinkChannel
|
||||
from common.logger import LOG
|
||||
from services.collector.base import CollectedFact, CollectedMedia, RawSource
|
||||
|
||||
SEARCH_URL = "https://nol.yanolja.com/"
|
||||
DETAIL_URL_RE = re.compile(r"nol\.yanolja\.com/stay/domestic/\d+", re.IGNORECASE)
|
||||
|
||||
# 검색결과 카드는 <a data-card-type="basic" ... aria-label="{업체명} 상품 상세 보기" href="...">
|
||||
RESULT_CARD_SELECTOR = 'a[data-card-type="basic"]'
|
||||
|
||||
SECTION_IDS = {
|
||||
"rooms": "PLACE_SECTION",
|
||||
"overview": "OVERVIEW_SECTION",
|
||||
"service": "SERVICE_SECTION",
|
||||
"policy": "POLICY_SECTION",
|
||||
"reservation": "RESERVATION_SECTION",
|
||||
}
|
||||
|
||||
# 원문을 보존할 섹션들. 생성 근거와 확정된 NOL 안내 표시가 같은 수집 원문을 쓴다.
|
||||
_TEXT_ONLY_SECTIONS = (
|
||||
("overview", "숙소 소개"),
|
||||
("service", "시설/서비스"),
|
||||
("policy", "이용 안내"),
|
||||
("reservation", "예약 공지"),
|
||||
)
|
||||
|
||||
_UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RoomInfo:
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
capacity: Optional[str] = None
|
||||
bed: Optional[str] = None
|
||||
images: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _extract_stay_id(url: str) -> Optional[str]:
|
||||
m = re.search(r"/stay/domestic/(\d+)", url)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _is_real_photo_url(src: str) -> bool:
|
||||
return bool(src) and "static/images" not in src
|
||||
|
||||
|
||||
def _parse_capacity(capacity: Optional[str]) -> tuple[Optional[str], Optional[str]]:
|
||||
if not capacity:
|
||||
return None, None
|
||||
std_m = re.search(r"기준\s*(\d+)인", capacity)
|
||||
max_m = re.search(r"최대\s*(\d+)인", capacity)
|
||||
return (std_m.group(1) if std_m else None, max_m.group(1) if max_m else None)
|
||||
|
||||
|
||||
def _parse_rooms_from_section_text(section_text: str) -> list[_RoomInfo]:
|
||||
"""PLACE_SECTION.innerText 는 "N / M"(사진 장수) 로 객실 카드 수만큼 반복된다.
|
||||
가격·예약 버튼 이하는 무시한다. 사이트 구조가 바뀌면 이 정규식도 손봐야 한다."""
|
||||
rooms: list[_RoomInfo] = []
|
||||
chunks = re.split(r"\n?\d+\s*\n/\n\d+\n", section_text)
|
||||
for chunk in chunks[1:]:
|
||||
lines = [l.strip() for l in chunk.split("\n") if l.strip()]
|
||||
if not lines:
|
||||
continue
|
||||
name = lines[0]
|
||||
capacity_m = re.search(r"기준\s*\d+인\s*/\s*최대\s*\d+인", chunk)
|
||||
bed_m = re.search(r"(킹|퀸|더블|싱글|트윈)\s*침대\s*\d+개", chunk)
|
||||
desc_lines = []
|
||||
for l in lines[1:]:
|
||||
if l.startswith("기준") or "체크인" in l or l == "숙박":
|
||||
break
|
||||
desc_lines.append(l.strip("()"))
|
||||
rooms.append(
|
||||
_RoomInfo(
|
||||
name=name,
|
||||
description=" ".join(desc_lines) if desc_lines else None,
|
||||
capacity=capacity_m.group(0) if capacity_m else None,
|
||||
bed=bed_m.group(0) if bed_m else None,
|
||||
)
|
||||
)
|
||||
return rooms
|
||||
|
||||
|
||||
async def _scroll_through_page(page: Page) -> None:
|
||||
prev_height = -1
|
||||
for _ in range(30):
|
||||
await page.mouse.wheel(0, 1200)
|
||||
await page.wait_for_timeout(250)
|
||||
cur_height = await page.evaluate("document.body.scrollHeight")
|
||||
if cur_height == prev_height:
|
||||
break
|
||||
prev_height = cur_height
|
||||
await page.evaluate("window.scrollTo(0, 0)")
|
||||
|
||||
|
||||
async def _get_section_text(page: Page, element_id: str) -> str:
|
||||
try:
|
||||
await page.evaluate(
|
||||
"(id) => { const el = document.getElementById(id); if (el) el.scrollIntoView({block:'center'}); }",
|
||||
element_id,
|
||||
)
|
||||
await page.wait_for_timeout(400)
|
||||
return await page.evaluate(
|
||||
"(id) => { const el = document.getElementById(id); return el ? el.innerText : ''; }",
|
||||
element_id,
|
||||
) or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def _get_room_images_by_name(page: Page, element_id: str) -> list[tuple[str, list[str]]]:
|
||||
"""카드 구조가 "사진 캐러셀 → <h2>객실명</h2> → 설명" 순이라, h2 를 만나기 전까지
|
||||
쌓인 이미지가 그 h2 의 몫이다."""
|
||||
try:
|
||||
await page.evaluate(
|
||||
"(id) => { const el = document.getElementById(id); if (el) el.scrollIntoView({block:'center'}); }",
|
||||
element_id,
|
||||
)
|
||||
await page.wait_for_timeout(400)
|
||||
raw = await page.evaluate(
|
||||
"""(id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return [];
|
||||
const nodes = el.querySelectorAll('img, h2');
|
||||
const result = [];
|
||||
let buf = [];
|
||||
for (const n of nodes) {
|
||||
if (n.tagName === 'IMG') {
|
||||
if (n.src) buf.push(n.src);
|
||||
} else if (n.tagName === 'H2') {
|
||||
result.push({name: n.textContent.trim(), images: [...new Set(buf)]});
|
||||
buf = [];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}""",
|
||||
element_id,
|
||||
) or []
|
||||
except Exception:
|
||||
return []
|
||||
return [(item["name"], [u for u in item["images"] if _is_real_photo_url(u)]) for item in raw]
|
||||
|
||||
|
||||
async def _get_section_images(page: Page, element_id: str) -> list[str]:
|
||||
try:
|
||||
await page.evaluate(
|
||||
"(id) => { const el = document.getElementById(id); if (el) el.scrollIntoView({block:'center'}); }",
|
||||
element_id,
|
||||
)
|
||||
await page.wait_for_timeout(400)
|
||||
srcs = await page.evaluate(
|
||||
"""(id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return [];
|
||||
return [...new Set(Array.from(el.querySelectorAll('img')).map(i => i.src).filter(Boolean))];
|
||||
}""",
|
||||
element_id,
|
||||
) or []
|
||||
except Exception:
|
||||
return []
|
||||
return [u for u in srcs if _is_real_photo_url(u)]
|
||||
|
||||
|
||||
async def _get_stay_name(page: Page) -> Optional[str]:
|
||||
try:
|
||||
h1 = await page.query_selector("h1")
|
||||
return (await h1.inner_text()).strip() if h1 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _new_page():
|
||||
"""호출마다 브라우저를 새로 띄우고 닫는다 — 다른 어댑터처럼 상태를 들고 있지 않는다."""
|
||||
pw = await async_playwright().start()
|
||||
browser = await pw.chromium.launch(headless=True)
|
||||
context = await browser.new_context(locale="ko-KR", user_agent=_UA)
|
||||
page = await context.new_page()
|
||||
return pw, browser, page
|
||||
|
||||
|
||||
async def search_by_address(address: str, timeout_ms: int = 15000) -> Optional[tuple[str, str]]:
|
||||
"""주소로 검색해 첫 검색결과의 (상세 URL, 업체명)을 돌려준다. 못 찾으면 None.
|
||||
|
||||
discover 단계(주소만 아는 상태에서 링크를 찾는 쪽)가 쓴다. 카드를 클릭하지 않고
|
||||
href 를 직접 읽어 이동한다 — 클릭 시 새 탭이 뜨거나 배너에 가로채이는 문제를 피한다.
|
||||
"""
|
||||
pw, browser, page = await _new_page()
|
||||
try:
|
||||
await page.goto(SEARCH_URL, wait_until="domcontentloaded")
|
||||
search_box = page.get_by_role("combobox", name="검색어 입력")
|
||||
await search_box.click()
|
||||
await search_box.fill(address)
|
||||
await search_box.press("Enter")
|
||||
|
||||
try:
|
||||
first_card = page.locator(RESULT_CARD_SELECTOR).first
|
||||
await first_card.wait_for(state="visible", timeout=timeout_ms)
|
||||
except PWTimeoutError:
|
||||
return None
|
||||
|
||||
name = await first_card.get_attribute("aria-label") or await first_card.inner_text()
|
||||
detail_url = await first_card.get_attribute("href")
|
||||
if not detail_url:
|
||||
return None
|
||||
|
||||
await page.goto(detail_url, wait_until="domcontentloaded")
|
||||
return page.url, name
|
||||
except Exception as ex:
|
||||
LOG.w(f"[yanolja] 주소 검색 실패(계속): {type(ex).__name__}: {ex}")
|
||||
return None
|
||||
finally:
|
||||
await browser.close()
|
||||
await pw.stop()
|
||||
|
||||
|
||||
class YanoljaAdapter:
|
||||
"""야놀자(NOL) 국내숙소 상세페이지 → 객실 fact·사진."""
|
||||
|
||||
id = "yanolja"
|
||||
|
||||
def can_handle(self, url: str) -> bool:
|
||||
return bool(DETAIL_URL_RE.search((url or "").lower()))
|
||||
|
||||
async def fetch(self, url: str) -> RawSource:
|
||||
pw, browser, page = await _new_page()
|
||||
try:
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
try:
|
||||
await page.wait_for_selector("h1", timeout=15000)
|
||||
except PWTimeoutError:
|
||||
# ★ 우회하지 않는다 — 차단·비정상 응답이면 그대로 실패로 돌린다.
|
||||
pass
|
||||
|
||||
name = await _get_stay_name(page)
|
||||
if not name:
|
||||
return RawSource.failure(url, self.id, "상세페이지를 읽지 못했다(차단되었거나 존재하지 않는 페이지)", LinkChannel.YANOLJA)
|
||||
|
||||
await _scroll_through_page(page)
|
||||
|
||||
rooms_text = await _get_section_text(page, SECTION_IDS["rooms"])
|
||||
rooms = _parse_rooms_from_section_text(rooms_text)
|
||||
|
||||
room_images = await _get_room_images_by_name(page, SECTION_IDS["rooms"])
|
||||
if len(room_images) == len(rooms):
|
||||
for room, (_, images) in zip(rooms, room_images):
|
||||
room.images = images
|
||||
else:
|
||||
images_by_name = dict(room_images)
|
||||
for room in rooms:
|
||||
room.images = images_by_name.get(room.name, [])
|
||||
|
||||
gallery = await _get_section_images(page, SECTION_IDS["overview"])
|
||||
|
||||
# 숙소소개·시설서비스·이용안내·예약공지 — fact 로 만들 스키마 필드가 없어
|
||||
# RawSource.text 로만 싣는다(생성 근거). intro 계열은 allow_llm=True 라
|
||||
# fact 로 만들면 안 된다(절대규칙 7, 2026-08-31 사고: TourAPI 가 원문을 그대로
|
||||
# intro 로 밀어넣어 LLM 소개문을 영영 못 보이게 만들었다).
|
||||
section_texts: list[str] = []
|
||||
for section_key, label in _TEXT_ONLY_SECTIONS:
|
||||
body = await _get_section_text(page, SECTION_IDS[section_key])
|
||||
if body.strip():
|
||||
section_texts.append(f"[{label}]\n{body.strip()}")
|
||||
page_text = "\n\n".join(section_texts)[:20000]
|
||||
except Exception as ex:
|
||||
return RawSource.failure(url, self.id, f"{type(ex).__name__}: {ex}", LinkChannel.YANOLJA)
|
||||
finally:
|
||||
await browser.close()
|
||||
await pw.stop()
|
||||
|
||||
facts: list[CollectedFact] = []
|
||||
media: list[CollectedMedia] = []
|
||||
|
||||
for src in gallery:
|
||||
media.append(CollectedMedia(origin_url=src, label="갤러리"))
|
||||
|
||||
for room in rooms:
|
||||
std_cap, max_cap = _parse_capacity(room.capacity)
|
||||
facts.append(CollectedFact(key="room_type", value=room.name, scope="unit", unit_name=room.name))
|
||||
if std_cap:
|
||||
facts.append(CollectedFact(key="standard_capacity", value=std_cap, scope="unit", unit_name=room.name))
|
||||
if max_cap:
|
||||
facts.append(CollectedFact(key="max_capacity", value=max_cap, scope="unit", unit_name=room.name))
|
||||
if room.bed:
|
||||
facts.append(CollectedFact(key="bed_type", value=room.bed, scope="unit", unit_name=room.name))
|
||||
# ★ room_intro 도 allow_llm=True 라 수집하지 않는다(위 intro 와 같은 이유).
|
||||
for src in room.images:
|
||||
media.append(CollectedMedia(origin_url=src, label="객실 사진", unit_name=room.name))
|
||||
|
||||
if not facts and not media:
|
||||
return RawSource.failure(url, self.id, "객실·소개 정보를 찾지 못했다", LinkChannel.YANOLJA)
|
||||
|
||||
LOG.i(f"[yanolja] {name} — 객실 {len(rooms)}개 · fact {len(facts)}건 · 사진 {len(media)}장")
|
||||
return RawSource(
|
||||
url=url,
|
||||
adapter_id=self.id,
|
||||
channel=LinkChannel.YANOLJA,
|
||||
text=page_text,
|
||||
facts=facts,
|
||||
media=media,
|
||||
)
|
||||
@ -10,6 +10,7 @@
|
||||
한때 이 네 가지가 한 파일 500줄에 뭉쳐 있었다. "FAQ 답이 이상하다" 를 고치러 와도
|
||||
어디를 봐야 할지가 파일 안에서 갈리지 않았다.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
@ -196,6 +197,71 @@ async def generate_copy(
|
||||
return result
|
||||
|
||||
|
||||
# ── 요약(summarize_text) ──────────────────────────────────────────────────
|
||||
# ★ generate_copy 와 다르다: 여기서 압축하는 문장은 **이미 승인된 값**이다(fact 로 저장된 intro·
|
||||
# room_intro). 새 사실을 만드는 게 아니라 같은 내용을 짧게 쓰는 것뿐이라 ground_check 를 다시
|
||||
# 걸지 않는다 — "사실을 더하지 마라"는 프롬프트 지시로 충분하다.
|
||||
_SUMMARY_CACHE: dict[str, str] = {}
|
||||
_SUMMARY_CACHE_MAX = 500
|
||||
_SUMMARY_PROMPT = (
|
||||
"다음 숙소 소개에서 핵심 특징 1~2개만 골라 한국어 한 문장, 공백 포함 60~80자로 요약해줘. "
|
||||
"원문에 없는 사실이나 과장 표현을 추가하지 말고, 선택한 사실의 조건과 부정 표현을 유지해. "
|
||||
"반복되는 상호명, 인사말, 홍보 수식어는 생략해. "
|
||||
"요약문만 출력하고 다른 말은 붙이지 마.\n\n"
|
||||
)
|
||||
|
||||
|
||||
async def summarize_text(
|
||||
text: str,
|
||||
*,
|
||||
model: str = DEFAULT_TEXT_MODEL,
|
||||
max_retries: int = 2,
|
||||
client: Optional[httpx.AsyncClient] = None,
|
||||
) -> Optional[str]:
|
||||
"""캔버스 미리보기용 축약문. 실패해도 예외를 올리지 않는다 — 호출측은 None 이면 원문을 쓴다.
|
||||
|
||||
★ DB 에 남기지 않는다. 같은 원문은 프로세스 메모리 캐시(sha256 키)로 재호출을 막는다
|
||||
(서버 재시작하면 비워진다 — 요구사항: "DB 저장은 생략하고 프론트 응답에만 실어준다").
|
||||
"""
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
if not is_configured():
|
||||
return None
|
||||
|
||||
# 길이 기준을 바꾼 뒤 이전 길이의 요약을 재사용하지 않도록 프롬프트도 키에 넣는다.
|
||||
cache_key = hashlib.sha256((_SUMMARY_PROMPT + stripped).encode("utf-8")).hexdigest()
|
||||
cached = _SUMMARY_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
body = {
|
||||
"contents": [{"role": "user", "parts": [{
|
||||
"text": _SUMMARY_PROMPT + stripped,
|
||||
}]}],
|
||||
"generationConfig": {"temperature": 0.2},
|
||||
}
|
||||
|
||||
owns_client = client is None
|
||||
client = client or httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0))
|
||||
try:
|
||||
payload = await call(client, model, body, max_retries)
|
||||
summary = extract_text(payload).strip()
|
||||
except GeminiError as ex:
|
||||
LOG.w(f"[gemini-text] 요약 실패: {ex}")
|
||||
return None
|
||||
finally:
|
||||
if owns_client:
|
||||
await client.aclose()
|
||||
|
||||
if not summary:
|
||||
return None
|
||||
if len(_SUMMARY_CACHE) >= _SUMMARY_CACHE_MAX:
|
||||
_SUMMARY_CACHE.clear() # 간단한 캐시 상한 — 관리 도구 트래픽 규모에는 LRU 가 과하다.
|
||||
_SUMMARY_CACHE[cache_key] = summary
|
||||
return summary
|
||||
|
||||
|
||||
@dataclass
|
||||
class GeneratedSong:
|
||||
"""가사 생성 결과. 곡은 여기서 만들지 않는다 — 작곡은 services/external/suno 다."""
|
||||
|
||||
57
solution/backend/services/external/restaurant_discovery.py
vendored
Normal file
57
solution/backend/services/external/restaurant_discovery.py
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
"""지역명으로 맛집 후보 이름을 찾는다.
|
||||
|
||||
★ services/external/perplexity.py(채널 발견: 단일 업소 → URL)와는 다른 용도다 —
|
||||
여기는 "이 지역에 뭐가 있나"를 묻는 지역 목록 검색이다. 원칙은 같다:
|
||||
Perplexity 응답을 사실로 쓰지 않는다. 이름만 후보로 받고, 실제 값은 이후
|
||||
네이버 크롤링(NaverPlaceAdapter)이 확정한다.
|
||||
"""
|
||||
import json
|
||||
|
||||
from common.logger import LOG
|
||||
from services.llm.perplexity import DEFAULT_MAX_TOKENS, DEFAULT_MODEL, PerplexityError, call
|
||||
from services.prompts.restaurant_search import RESPONSE_SCHEMA, SYSTEM_PROMPT, build_prompt
|
||||
|
||||
MAX_RESULTS = 10
|
||||
|
||||
|
||||
def _parse_names(payload: dict) -> list[str]:
|
||||
choices = payload.get("choices") or []
|
||||
if not choices or not isinstance(choices[0], dict):
|
||||
return []
|
||||
content = ((choices[0].get("message") or {}).get("content")) or ""
|
||||
try:
|
||||
doc = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
LOG.w("[restaurant_discovery] 구조화 출력 파싱 실패")
|
||||
return []
|
||||
names = doc.get("restaurants")
|
||||
if not isinstance(names, list):
|
||||
return []
|
||||
return [str(n).strip() for n in names if str(n or "").strip()][:MAX_RESULTS]
|
||||
|
||||
|
||||
async def search_region_restaurants(
|
||||
region_label: str, *, model: str = DEFAULT_MODEL, client=None,
|
||||
) -> list[str]:
|
||||
"""지역명으로 맛집 상위 10곳의 이름만 받는다.
|
||||
|
||||
실패(미설정·타임아웃·5xx)하면 빈 목록 — 호출측이 TourAPI 결과만으로 계속 진행한다.
|
||||
"""
|
||||
if not (region_label or "").strip():
|
||||
return []
|
||||
body = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": build_prompt(region_label)},
|
||||
],
|
||||
"max_tokens": DEFAULT_MAX_TOKENS,
|
||||
"temperature": 0,
|
||||
"response_format": RESPONSE_SCHEMA,
|
||||
}
|
||||
try:
|
||||
payload = await call(body, client=client)
|
||||
except PerplexityError as ex:
|
||||
LOG.w(f"[restaurant_discovery] '{region_label}' 검색 실패: {ex}")
|
||||
return []
|
||||
return _parse_names(payload)
|
||||
@ -33,6 +33,7 @@ from router.v1.fact.protocol import (
|
||||
Res_FactList,
|
||||
)
|
||||
from services.external.gemini_extract import extract_facts
|
||||
from services.intro_summary import summarize_intro
|
||||
from services.llm.gemini import GeminiError, GeminiNotConfigured
|
||||
|
||||
# 사장님이 붙여넣은 원문의 출처 표기. ★ 실제 URL 이 아니라 경로 식별자다 —
|
||||
@ -40,7 +41,7 @@ from services.llm.gemini import GeminiError, GeminiNotConfigured
|
||||
# '어디서 왔는가' 는 여전히 명확하다: 사장님이 화면에 직접 붙여넣었다.
|
||||
OWNER_PASTE_SOURCE = "owner:paste"
|
||||
|
||||
# 자동 수집 출처 — 노출값을 직접 바꾸지 못하고 후보로만 들어간다.
|
||||
# 자동 출처. CRAWL과 LLM 문장은 아래의 즉시 노출 경로를 먼저 거친다.
|
||||
_AUTO_SOURCES = (SourceType.API, SourceType.CRAWL, SourceType.LLM)
|
||||
|
||||
|
||||
@ -48,10 +49,9 @@ class FactService:
|
||||
"""fact 기록 + 검증 상태 전이.
|
||||
|
||||
── 세 갈래 프로세스 ────────────────────────────────────────────────
|
||||
생성 : 자동 수집 → 후보(UNVERIFIED) → 사람이 승인 → 노출값(VERIFIED) → 사이트 빌드
|
||||
업데이트: 재수집 → 값이 같으면 검증 유지(REFRESHED)
|
||||
값이 다르면 **노출값은 그대로 두고** 후보(PENDING_OWNER)로 적재
|
||||
→ 사람이 승인해야 노출값이 교체된다
|
||||
생성 : 크롤링 → 노출값(VERIFIED), API → 후보 → 사람이 승인
|
||||
업데이트: 같은 값이면 검증 유지(REFRESHED). 다른 CRAWL 값은 바로 교체한다.
|
||||
API 값이나 사람 입력·정정본과 충돌하는 값은 후보(PENDING_OWNER)로 적재한다.
|
||||
수정 : 사람이 직접 입력 → 노출값 즉시 교체. 정정(CORRECTED)은 잠금 표시가 붙는다
|
||||
문장 : LLM 이 쓴 소개문·메타(allow_llm 필드) → 승인 없이 바로 노출값
|
||||
(사실이 아니라 **이미 승인된 사실로 쓴 문장**이다 — upsert_fact 주석)
|
||||
@ -63,8 +63,8 @@ class FactService:
|
||||
1. key 는 사업장 업종 스키마에 있는 것만 (FACT_INVALID_KEY)
|
||||
2. owner 가 아닌 출처는 source_url 필수 (FACT_SOURCE_REQUIRED)
|
||||
3. LLM 은 스키마가 허용한 문장 필드에만 쓴다 (절대규칙 7)
|
||||
4. 자동 수집은 노출값을 직접 못 바꾼다 — 후보로만 (절대규칙 1·6).
|
||||
★ 예외는 LLM 문장 하나뿐이고, 그것도 CORRECTED 는 못 덮는다
|
||||
4. 크롤링은 승인 없이 노출한다. 사람 입력·정정본은 덮지 않는다.
|
||||
LLM 문장도 즉시 노출하되 CORRECTED는 보존한다.
|
||||
5. 상태 전이는 FACT_STATUS_TRANSITIONS 에 있는 것만
|
||||
"""
|
||||
|
||||
@ -133,12 +133,21 @@ class FactService:
|
||||
res.result.SetResult(list_err)
|
||||
return res
|
||||
res.facts = [FactData.model_validate(r) for r in rows]
|
||||
await self._attach_summaries(res.facts)
|
||||
# ★ 사이트에 나갈 수 있는 건수. 발행 게이트가 보는 숫자와 같은 기준이다.
|
||||
res.publishable = sum(1 for r in rows if FactStatus(r.status) in PUBLISHABLE_FACT_STATUSES)
|
||||
# 재수집이 올려놓은 확인 대기 건수 — 관리 화면의 '검토할 것' 배지.
|
||||
res.pending_review = sum(1 for r in rows if FactStatus(r.status) == FactStatus.PENDING_OWNER)
|
||||
return res
|
||||
|
||||
async def _attach_summaries(self, facts: list) -> None:
|
||||
"""intro/room_intro 원문이 길면 캔버스 미리보기용 요약을 얹는다.
|
||||
|
||||
★ place_facts 에는 저장하지 않는다 — 이 응답(FactData.summary)에만 실린다.
|
||||
원문이 짧으면 API 를 부르지 않는다(summarize_intro)."""
|
||||
for f in facts:
|
||||
f.summary = await summarize_intro(f.key, f.value)
|
||||
|
||||
# ---- 기록 ----
|
||||
async def extract_from_text(self, user_info: UserInfo, place_id: str, text: str) -> Res_ExtractFacts:
|
||||
"""사장님이 붙여넣은 원문 → fact 후보.
|
||||
@ -237,7 +246,8 @@ class FactService:
|
||||
"""fact 를 기록한다. 출처에 따라 경로가 갈린다.
|
||||
|
||||
사람(owner) → 노출값을 직접 교체한다(수정 프로세스)
|
||||
자동 수집 → 노출값과 같으면 확인 시각만 갱신, 다르면 후보로 적재(업데이트 프로세스)
|
||||
크롤링 → 원값을 바로 노출, 직접 입력·정정본과 충돌하면 후보로 보존
|
||||
API → 노출값과 같으면 확인 시각만 갱신, 다르면 후보로 적재
|
||||
"""
|
||||
res = Res_Fact()
|
||||
err_type, place = await self._load_place(user_info, place_id)
|
||||
@ -279,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(
|
||||
@ -305,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)
|
||||
@ -367,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,
|
||||
@ -380,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,
|
||||
@ -390,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:
|
||||
|
||||
@ -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):
|
||||
|
||||
10
solution/backend/services/intro_summary.py
Normal file
10
solution/backend/services/intro_summary.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""정보 조회와 발행 payload가 같은 조건으로 소개문 요약을 사용한다."""
|
||||
from services.external import gemini_text
|
||||
|
||||
|
||||
async def summarize_intro(key: str, value: str | None) -> str | None:
|
||||
# 짧은 소개까지 유료 호출하지 않는다. 원문은 DB에 그대로 두고 응답만 보강한다.
|
||||
text = (value or "").strip()
|
||||
if key not in {"intro", "room_intro"} or len(text) <= 80:
|
||||
return None
|
||||
return await gemini_text.summarize_text(text)
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
239
solution/backend/services/local_restaurant_enrichment.py
Normal file
239
solution/backend/services/local_restaurant_enrichment.py
Normal file
@ -0,0 +1,239 @@
|
||||
"""주변 맛집 보강 — TourAPI 데이터가 중심이고, Perplexity+네이버 크롤링은 부수적인 보강이다.
|
||||
|
||||
★ TourAPI 로 이미 있는 맛집은 몇 건이든(군산 절골길 18처럼 59건이어도) 그대로 전부 보여준다 —
|
||||
이 모듈은 그중 어떤 것도 지우거나 숨기지 않는다(2026-09-14 사용자 확정). Perplexity 지역검색
|
||||
상위 10개 이름 중 TourAPI(또는 이전에 이미 크롤링해 둔 것)에 없는 이름만 네이버에서 크롤링해
|
||||
**추가**한다 — "상위 10개"는 Perplexity 검색 후보의 상한일 뿐, 최종 화면에 보이는 개수의
|
||||
상한이 아니다.
|
||||
★ 네이버 URL 확보는 `services/external/naver_place_lookup.py`(상호명+지역으로 네이버 자체 검색 →
|
||||
place id)를 그대로 재사용한다 — Perplexity 도메인필터 재검색으로 시도했다가 실측(2026-09-14,
|
||||
군산시 6곳 중 0곳 성공)에서 명중률이 낮아 이 기존 모듈로 바꿨다.
|
||||
★ docs/DECISIONS.md 1-1 예외 처리. 설계: tmp/superpowers/specs/2026-09-14-nearby-restaurant-naver-enrichment-design.md
|
||||
봇 탐지 우회는 하지 않는다 — 막히면 그 업체만 포기한다.
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import area_contents, place_area_refs
|
||||
from common.enums import AREA_KIND, DBWRType, ErrorType, LocalContentStatus, LocalContentType, LocalSource
|
||||
from common.logger import LOG
|
||||
from common.utils.geo import haversine_m
|
||||
from crud.place_content_crud import PlaceContentCRUD
|
||||
from services.collector.naver_place_adapter import NaverPlaceAdapter
|
||||
from services.external import naver_place_lookup, perplexity
|
||||
from services.external.restaurant_discovery import search_region_restaurants
|
||||
|
||||
_NORM_STRIP = re.compile(r"[\s,·.\-_'\"()&]")
|
||||
|
||||
_BODY_DROP = ("contentid", "content_type", "distance_m", "latitude", "longitude")
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""비교용 정규화. 공백·구두점·괄호를 지우고 소문자로 낮춘다."""
|
||||
return _NORM_STRIP.sub("", (name or "")).lower()
|
||||
|
||||
|
||||
def is_same_restaurant(a: str, b: str) -> bool:
|
||||
"""이름 유사도 판정. 표기 차이와 지점명 접미사("...본점")는 같은 곳으로 본다.
|
||||
|
||||
★ 부분 문자열 포함으로 판정한다 — 완전 일치만 보면 "이든식당"과 "이든식당 본점"이
|
||||
다른 곳으로 갈려 TourAPI에 이미 있는 곳을 중복으로 다시 크롤링한다.
|
||||
"""
|
||||
na, nb = normalize_name(a), normalize_name(b)
|
||||
if not na or not nb:
|
||||
return False
|
||||
return na == nb or na in nb or nb in na
|
||||
|
||||
|
||||
def to_area_content_body(summary: dict) -> dict:
|
||||
"""NaverPlaceAdapter.fetch_summary() 결과 → tour_api._normalize()와 같은 모양의 dict.
|
||||
|
||||
★ distance_m 은 항상 None 이다 — 지역명 검색으로 찾은 업체라 업장 좌표 기준 거리를
|
||||
모른다. local_content_service 의 body["distance_m"] 직접 접근 규약을 지키려면
|
||||
키 자체는 있어야 한다(값만 비운다).
|
||||
"""
|
||||
out = {
|
||||
"contentid": summary["place_id"],
|
||||
"content_type": LocalContentType.RESTAURANT.value,
|
||||
"distance_m": None,
|
||||
"name": summary["name"],
|
||||
"searchQuery": summary["name"],
|
||||
}
|
||||
if summary.get("address"):
|
||||
out["location"] = summary["address"]
|
||||
if summary.get("latitude"):
|
||||
out["latitude"] = summary["latitude"]
|
||||
if summary.get("longitude"):
|
||||
out["longitude"] = summary["longitude"]
|
||||
if summary.get("imageUrl"):
|
||||
out["imageUrl"] = summary["imageUrl"]
|
||||
return out
|
||||
|
||||
|
||||
def _as_float(value) -> float | None:
|
||||
try:
|
||||
return float(value) if value not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _distance_to_place(place_coords: tuple[float, float] | None, summary: dict) -> int | None:
|
||||
"""업장 좌표 ↔ 크롤링한 맛집 좌표 거리(m). 둘 중 하나라도 없으면 None.
|
||||
|
||||
★ 외부 호출 없는 순수 계산이다 — 좌표는 이미 fetch_summary()가 같은 응답에서 받아 온
|
||||
값이라 이 계산에 드는 비용은 없다(common.utils.geo.haversine_m 재사용).
|
||||
"""
|
||||
if place_coords is None:
|
||||
return None
|
||||
lat, lng = _as_float(summary.get("latitude")), _as_float(summary.get("longitude"))
|
||||
if lat is None or lng is None:
|
||||
return None
|
||||
return round(haversine_m(place_coords[0], place_coords[1], lat, lng))
|
||||
|
||||
|
||||
async def _place_coordinates(place_id) -> tuple[float, float] | None:
|
||||
"""이 place 자신의 좌표. 크롤링한 맛집과의 거리 계산용으로만 쓴다.
|
||||
|
||||
★ 순환 import 회피: story_service → 이 모듈로 이어지는 사슬이 있어 지연 import 한다.
|
||||
"""
|
||||
from services.local_content_service import LocalContentService
|
||||
|
||||
place = await LocalContentService()._load_place(place_id)
|
||||
if place is None:
|
||||
return None
|
||||
lat, lng = _as_float(getattr(place, "latitude", None)), _as_float(getattr(place, "longitude", None))
|
||||
if lat is None or lng is None:
|
||||
return None
|
||||
return lat, lng
|
||||
|
||||
|
||||
async def _sync_site_personalization(place_id, restaurant_refs: list) -> None:
|
||||
"""`place_area_refs` 기준 맛집 연결 중 사이트 개인화 맵(site_sections.local)에 없는
|
||||
것만 채운다 — 이미 있는 값(거리·숨김)은 건드리지 않는다.
|
||||
|
||||
restaurant_refs: [(content_id, distance_m, hidden), ...] — 기존 연결 + 이번에 새로
|
||||
크롤링한 것 전부. 기존 것이 이미 맵에 있으면 손대지 않고, 없는 것(새로 추가한 것,
|
||||
또는 과거에 맵 갱신 없이 만들어진 것)만 채운다 — 그래서 자연히 자가복구도 된다.
|
||||
|
||||
★ 캔버스(스냅샷)는 `place_area_refs`가 아니라 이 맵만 읽는다(services/snapshot.py::
|
||||
_site_places). `place_area_refs`에만 쓰고 여기를 안 채우면, DB에는 들어가도 화면에는
|
||||
안 나온다.
|
||||
★ 순환 import 회피: local_content_service → story_service → 이 모듈로 이어지는 사슬이 있어
|
||||
지연 import 한다(story_service.run_local_sync 의 관례와 동일).
|
||||
"""
|
||||
if not restaurant_refs:
|
||||
return
|
||||
from services.local_content_service import LocalContentService
|
||||
from services.snapshot import _site_places
|
||||
|
||||
places_map = dict(await _site_places(place_id))
|
||||
changed = False
|
||||
for content_id, distance_m, hidden in restaurant_refs:
|
||||
key = str(content_id)
|
||||
if key in places_map:
|
||||
continue
|
||||
places_map[key] = {"kind": "restaurant", "distanceMeters": distance_m, "hidden": bool(hidden)}
|
||||
changed = True
|
||||
if changed:
|
||||
await LocalContentService()._write_site_places(place_id, places_map)
|
||||
|
||||
|
||||
async def enrich_place_restaurants(place_id, region_label: str, region_code: str | None = None) -> dict:
|
||||
"""이 place 의 기존 맛집(TourAPI 등)은 그대로 두고, Perplexity 지역검색 상위 10개 이름 중
|
||||
아직 없는 곳만 네이버에서 크롤링해 추가한다. 기존 연결을 지우거나 숨기지 않는다.
|
||||
|
||||
흐름: ① Perplexity 로 이 지역 맛집 상위 10개 이름을 받는다 → ② 이름마다 이 place 에 이미
|
||||
연결된 맛집(TourAPI 또는 이전 크롤링분)과 유사도 매칭 — 있으면 건너뛰고(중복 크롤링 방지),
|
||||
없으면 네이버에서 크롤링해 새로 연결한다. 실패해도 예외를 던지지 않는다.
|
||||
"""
|
||||
stats = {"matched": 0, "added": 0, "checked": 0, "skipped": ""}
|
||||
|
||||
if not perplexity.is_configured():
|
||||
stats["skipped"] = "PERPLEXITY_API_KEY 미설정"
|
||||
return stats
|
||||
if not (region_label or "").strip():
|
||||
stats["skipped"] = "region_label 없음"
|
||||
return stats
|
||||
|
||||
names = await search_region_restaurants(region_label)
|
||||
if not names:
|
||||
stats["skipped"] = "Perplexity 검색 결과 없음"
|
||||
return stats
|
||||
|
||||
crud = PlaceContentCRUD()
|
||||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
place_area_refs.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: crud.list_by_place(s, place_id),
|
||||
)
|
||||
if err != ErrorType.SUCCESS:
|
||||
stats["skipped"] = "기존 목록 조회 실패"
|
||||
return stats
|
||||
|
||||
existing = [r for r in (rows or []) if int(r.content_type) == LocalContentType.RESTAURANT.value]
|
||||
existing_titles = [r.title for r in existing if r.title]
|
||||
now = datetime.now(timezone.utc)
|
||||
place_coords = await _place_coordinates(place_id)
|
||||
new_entries: list = [] # [(content_id, distance_m), ...]
|
||||
|
||||
for name in names:
|
||||
stats["checked"] += 1
|
||||
if any(is_same_restaurant(name, title) for title in existing_titles):
|
||||
stats["matched"] += 1
|
||||
continue
|
||||
|
||||
naver_id = await naver_place_lookup.find_place_id(name, region_label)
|
||||
if not naver_id:
|
||||
continue
|
||||
|
||||
summary = await NaverPlaceAdapter().fetch_summary(naver_place_lookup.place_url(naver_id))
|
||||
if not summary:
|
||||
# ★ naver_place_lookup 이 찾은 id가 실제 상세 페이지가 아닐 수 있다(검색 원문에서
|
||||
# 상호 근처의 다른 숫자를 잘못 집은 경우) — 조용히 넘어가면 왜 스킵됐는지 안 보인다.
|
||||
LOG.w(f"[restaurant_enrich] '{name}' place={naver_id} 상세 조회 실패 — 포기")
|
||||
continue
|
||||
|
||||
body = to_area_content_body(summary)
|
||||
content_values = {
|
||||
"source": LocalSource.NAVER_CRAWL.value,
|
||||
"external_id": body["contentid"],
|
||||
"content_type": body["content_type"],
|
||||
"kind": AREA_KIND.get(body["content_type"]),
|
||||
"title": body["name"],
|
||||
"body": {k: v for k, v in body.items() if k not in _BODY_DROP},
|
||||
"latitude": _as_float(body.get("latitude")),
|
||||
"longitude": _as_float(body.get("longitude")),
|
||||
"region_code": region_code,
|
||||
"status": LocalContentStatus.PUBLISHED.value,
|
||||
"display_end_at": None,
|
||||
"collected_at": now,
|
||||
}
|
||||
write_err = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[area_contents.DBType()],
|
||||
[lambda s, v=content_values: crud.upsert_content(s, v)],
|
||||
)
|
||||
if write_err != ErrorType.SUCCESS:
|
||||
continue
|
||||
err_i, rows_i = await DB_SESSION_MNG.execute_lambda(
|
||||
area_contents.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s, e=body["contentid"]: crud.find_content_id(s, LocalSource.NAVER_CRAWL.value, e),
|
||||
)
|
||||
if err_i != ErrorType.SUCCESS or not rows_i:
|
||||
continue
|
||||
content_id = rows_i[0]
|
||||
distance_m = _distance_to_place(place_coords, summary)
|
||||
await DB_SESSION_MNG.execute_lambda_run(
|
||||
[place_area_refs.DBType()],
|
||||
[lambda s, cid=content_id, d=distance_m: crud.upsert_ref(s, place_id, cid, d)],
|
||||
)
|
||||
|
||||
existing_titles.append(name)
|
||||
new_entries.append((content_id, distance_m))
|
||||
stats["added"] += 1
|
||||
LOG.i(f"[restaurant_enrich] place={place_id} '{name}' 네이버 크롤링으로 추가"
|
||||
f"{f' (거리 {distance_m}m)' if distance_m is not None else ''}")
|
||||
|
||||
restaurant_refs = [(r.local_content_id, r.distance_m, r.hidden) for r in existing]
|
||||
restaurant_refs += [(cid, dist, False) for cid, dist in new_entries]
|
||||
await _sync_site_personalization(place_id, restaurant_refs)
|
||||
return stats
|
||||
33
solution/backend/services/prompts/restaurant_search.py
Normal file
33
solution/backend/services/prompts/restaurant_search.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""Prompt contract for regional restaurant name discovery (Perplexity)."""
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"너는 지역 맛집 조사원이다. 실제로 영업 중인 음식점 상호명만 나열한다. "
|
||||
"설명이나 추천 이유는 쓰지 않는다."
|
||||
)
|
||||
|
||||
RESPONSE_SCHEMA = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"restaurants": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
}
|
||||
},
|
||||
"required": ["restaurants"],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_prompt(region_label: str) -> str:
|
||||
return "\n".join([
|
||||
f"지역: {region_label}",
|
||||
"",
|
||||
"이 지역에서 실제로 영업 중인 인기 음식점 상호명을 최대 10개 나열하라.",
|
||||
"- 상호명만 반환하고 주소·메뉴·설명은 쓰지 마라.",
|
||||
"- 폐업했거나 존재가 확실하지 않은 곳은 제외한다.",
|
||||
"- 확실한 곳이 10개 미만이면 있는 만큼만 반환한다.",
|
||||
])
|
||||
@ -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} "
|
||||
|
||||
@ -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 한 곳에만 있다. 확인과 저장이 그것을 같이 쓴다.
|
||||
|
||||
@ -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 {}
|
||||
|
||||
77
solution/backend/services/stay_guide.py
Normal file
77
solution/backend/services/stay_guide.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""확정된 NOL 링크의 수집 원문에서 공개 안내 섹션만 전달한다."""
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
def nol_stay_guide(url: str, raw) -> dict:
|
||||
try:
|
||||
parsed = urlsplit(url)
|
||||
except ValueError:
|
||||
return {}
|
||||
if parsed.scheme != "https" or parsed.hostname != "nol.yanolja.com":
|
||||
return {}
|
||||
if not re.fullmatch(r"/stay/domestic/\d+/?", parsed.path):
|
||||
return {}
|
||||
text = raw.get("text") if isinstance(raw, dict) else None
|
||||
if not isinstance(text, str):
|
||||
return {}
|
||||
|
||||
labels = {"시설/서비스": "service", "이용 안내": "policy", "예약 공지": "reservation"}
|
||||
# 수집기가 붙인 경계로만 나눈다. 규정 안의 괄호나 문장은 분리하지 않는다.
|
||||
sections = re.split(r"(?m)^\[(숙소 소개|시설/서비스|이용 안내|예약 공지)\]\s*\n", text)
|
||||
guide = {}
|
||||
for label, body in zip(sections[1::2], sections[2::2]):
|
||||
if label not in labels:
|
||||
continue
|
||||
lines = body.strip().splitlines()
|
||||
if lines and lines[0].strip() == label:
|
||||
lines.pop(0)
|
||||
# 제목 중복과 UI 버튼만 제외한다. 요약·추론 없이 수집 문장을 보존한다.
|
||||
body = "\n".join(line for line in lines if line.strip() != "전체보기").strip()
|
||||
if body:
|
||||
guide[labels[label]] = body
|
||||
if guide:
|
||||
guide["fields"] = structured_fields(guide)
|
||||
return guide
|
||||
|
||||
|
||||
def structured_fields(guide: dict) -> list[dict]:
|
||||
"""확실한 표기만 구조화한다. 매칭되지 않은 내용은 안내 원문에 남는다."""
|
||||
policy = guide.get("policy", "")
|
||||
service = guide.get("service", "")
|
||||
reservation = guide.get("reservation", "")
|
||||
lines = {line.strip().lstrip("- ") for line in (policy + "\n" + reservation).splitlines()}
|
||||
facilities = {line.strip() for line in service.splitlines()}
|
||||
fields = []
|
||||
|
||||
def add(key, label, value, group="rules", note=None):
|
||||
fields.append(dict(key=key, label=label, value=value, group=group,
|
||||
**({"note": note} if note else {})))
|
||||
|
||||
for token, key, label in (("체크인", "check_in_time", "체크인 시간"),
|
||||
("체크아웃", "check_out_time", "체크아웃 시간")):
|
||||
matches = set(re.findall(rf"{token}\s+([0-2]\d:[0-5]\d)(?!\d)", policy))
|
||||
if len(matches) == 1:
|
||||
value = matches.pop()
|
||||
if int(value[:2]) < 24:
|
||||
add(key, label, value)
|
||||
fees = [re.fullmatch(r"전 연령 동일 1인당\s*([\d,]+)\s*(만)?원", line) for line in lines]
|
||||
amounts = {int(m[1].replace(",", "")) * (10000 if m[2] else 1) for m in fees if m}
|
||||
if len(amounts) == 1:
|
||||
add("extra_person_fee", "인원 추가 요금", f"{amounts.pop():,}원", note="1인당 · 전 연령 동일")
|
||||
if "반려동물 입실금지" in lines and not any("반려동물 입실가능" == line for line in lines):
|
||||
add("pet_allowed", "반려동물 동반", "불가")
|
||||
if "전 구역 금연" in lines:
|
||||
add("smoking", "흡연", "불가", "facilities", "전 구역 금연")
|
||||
for token, key, label in (("주차가능", "parking", "주차"), ("와이파이", "wifi", "와이파이"),
|
||||
("취사가능", "cooking_allowed", "취사")):
|
||||
if token in facilities:
|
||||
restrictions = [line.strip().lstrip("- ") for line in reservation.splitlines()
|
||||
if "조리금지" in line or "조리 금지" in line]
|
||||
add(key, label, "가능", "facilities",
|
||||
"\n".join(restrictions) if key == "cooking_allowed" and restrictions else None)
|
||||
known = [name for name in ("욕조", "개별 화장실", "주방", "테라스/발코니", "OTT (스트리밍 서비스)",
|
||||
"다이닝룸", "벽난로", "어메니티", "카페형룸") if name in facilities]
|
||||
if known:
|
||||
add("facilities", "부대시설", ", ".join(known), "facilities")
|
||||
return fields
|
||||
@ -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 로 막는다.
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
수정 : 사람이 직접 넣으면 즉시 노출값 교체 + 재빌드 대상 표시
|
||||
"""
|
||||
from common.enums import ErrorType, FactStatus, FactWriteOutcome, PlaceCategory, SourceType
|
||||
from services.external import gemini_text as gt
|
||||
|
||||
OTA = "https://ota.test/room/1"
|
||||
|
||||
@ -320,3 +321,60 @@ async def test_facts_are_scoped_to_owner(auth_headers, client):
|
||||
h2 = await auth_headers("o2")
|
||||
r = await client.get(f"/v1/place/{pid}/fact/list", headers=h2)
|
||||
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
|
||||
|
||||
|
||||
# ── 캔버스 미리보기 요약 ────────────────────────────────────────────────────
|
||||
async def test_long_intro_gets_ai_summary_in_list(auth_headers, client, monkeypatch):
|
||||
"""검증: intro 가 요약 임계치(200자)를 넘는다.
|
||||
기대결과: /fact/list 응답의 summary 에 축약문이 실리고, value(원문)는 그대로 남는다."""
|
||||
async def _fake_summarize(text, **_kwargs):
|
||||
return "축약된 소개문입니다."
|
||||
monkeypatch.setattr(gt, "summarize_text", _fake_summarize)
|
||||
|
||||
h = await auth_headers("u1")
|
||||
pid = await _verified_place(client, h, kakao="sum1")
|
||||
long_intro = "조용한 숙소입니다. " * 30 # 200자 초과
|
||||
await _crawl(client, h, pid, "intro", long_intro, SourceType.LLM)
|
||||
|
||||
facts = await _facts(client, h, pid)
|
||||
fact = next(f for f in facts["facts"] if f["key"] == "intro")
|
||||
assert fact["value"] == long_intro
|
||||
assert fact["summary"] == "축약된 소개문입니다."
|
||||
|
||||
|
||||
async def test_short_intro_has_no_summary(auth_headers, client, monkeypatch):
|
||||
"""검증: intro 가 임계치보다 짧다.
|
||||
기대결과: summary 가 비어 있고, 요약 API 는 아예 불리지 않는다."""
|
||||
calls = []
|
||||
async def _fake_summarize(text, **_kwargs):
|
||||
calls.append(text)
|
||||
return "호출되면 안 된다"
|
||||
monkeypatch.setattr(gt, "summarize_text", _fake_summarize)
|
||||
|
||||
h = await auth_headers("u1")
|
||||
pid = await _verified_place(client, h, kakao="sum2")
|
||||
await _crawl(client, h, pid, "intro", "조용한 숙소입니다.", SourceType.LLM)
|
||||
|
||||
facts = await _facts(client, h, pid)
|
||||
fact = next(f for f in facts["facts"] if f["key"] == "intro")
|
||||
assert fact.get("summary") is None
|
||||
assert calls == [], "짧은 문장인데 요약 API 를 불렀다"
|
||||
|
||||
|
||||
async def test_non_intro_fact_never_gets_summary(auth_headers, client, monkeypatch):
|
||||
"""검증: 길어도 intro/room_intro 가 아닌 key(예: cancel_policy).
|
||||
기대결과: 대상 key 가 아니므로 summary 를 만들지 않는다."""
|
||||
calls = []
|
||||
async def _fake_summarize(text, **_kwargs):
|
||||
calls.append(text)
|
||||
return "호출되면 안 된다"
|
||||
monkeypatch.setattr(gt, "summarize_text", _fake_summarize)
|
||||
|
||||
h = await auth_headers("u1")
|
||||
pid = await _verified_place(client, h, kakao="sum3")
|
||||
await _own(client, h, pid, "cancel_policy", "환불 규정 안내입니다. " * 30)
|
||||
|
||||
facts = await _facts(client, h, pid)
|
||||
fact = next(f for f in facts["facts"] if f["key"] == "cancel_policy")
|
||||
assert fact.get("summary") is None
|
||||
assert calls == []
|
||||
|
||||
@ -361,3 +361,64 @@ def test_ground_check_grounds_numbers_from_unit_summaries():
|
||||
grounding = FACTS + gt._unit_facts([{"name": "A동", "facts": {"max_capacity": "4"}}])
|
||||
ok, _reasons = grounding_copy.ground_check("A동은 최대 4명까지 이용하실 수 있습니다.", grounding)
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ── 요약(summarize_text) ──────────────────────────────────────────────────
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_summary_cache():
|
||||
"""모듈 전역 캐시가 테스트끼리 새어 들어가지 않게 매번 비운다."""
|
||||
gt._SUMMARY_CACHE.clear()
|
||||
|
||||
|
||||
async def test_summarize_returns_shortened_text():
|
||||
"""검증: 긴 문장을 요약 API 로 축약한다.
|
||||
기대결과: 응답 텍스트가 그대로 반환되고, 원문이 요청 프롬프트에 실린다."""
|
||||
payload = {
|
||||
"candidates": [{"content": {"parts": [{"text": "짧게 줄인 문장입니다."}]}, "finishReason": "STOP"}],
|
||||
"usageMetadata": {"promptTokenCount": 300, "candidatesTokenCount": 20},
|
||||
}
|
||||
calls = []
|
||||
async with _client(_ok(payload, calls)) as c:
|
||||
result = await gt.summarize_text("첫 번째 테스트용 원문입니다. " * 20, client=c)
|
||||
|
||||
assert result == "짧게 줄인 문장입니다."
|
||||
prompt = json.loads(calls[0].content)["contents"][0]["parts"][0]["text"]
|
||||
assert "첫 번째 테스트용 원문입니다." in prompt
|
||||
|
||||
|
||||
async def test_summarize_skips_when_not_configured():
|
||||
"""검증: GEMINI_API_KEY 미설정.
|
||||
기대결과: 호출 자체를 안 하고 None — 캔버스는 원문으로 폴백한다."""
|
||||
llm.external_api_config.gemini_api_key = ""
|
||||
result = await gt.summarize_text("두 번째 테스트용 원문입니다. " * 20)
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_summarize_caches_repeated_calls():
|
||||
"""검증: 같은 원문을 두 번 요약 요청한다.
|
||||
기대결과: 두 번째는 API 를 다시 부르지 않고 캐시된 값을 돌려준다."""
|
||||
payload = {"candidates": [{"content": {"parts": [{"text": "캐시 확인용 요약"}]}, "finishReason": "STOP"}]}
|
||||
calls = []
|
||||
text = "세 번째 테스트용 원문입니다. " * 20
|
||||
async with _client(_ok(payload, calls)) as c:
|
||||
first = await gt.summarize_text(text, client=c)
|
||||
second = await gt.summarize_text(text, client=c)
|
||||
|
||||
assert first == second == "캐시 확인용 요약"
|
||||
assert len(calls) == 1, "같은 원문인데 API 를 두 번 불렀다"
|
||||
|
||||
|
||||
async def test_summarize_returns_none_on_repeated_failure():
|
||||
"""검증: 재시도까지 전부 5xx 로 실패한다.
|
||||
기대결과: 예외를 올리지 않고 None — 요약 실패가 캔버스를 깨면 안 된다."""
|
||||
async with _client(lambda r: httpx.Response(503, text="unavailable")) as c:
|
||||
result = await gt.summarize_text("네 번째 테스트용 원문입니다. " * 20, client=c, max_retries=1)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_summarize_empty_text_returns_none():
|
||||
"""검증: 빈 문자열.
|
||||
기대결과: 호출 없이 None."""
|
||||
result = await gt.summarize_text(" ")
|
||||
assert result is None
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
*/
|
||||
import type { FactDataUnitId } from './factDataUnitId';
|
||||
import type { FactDataValue } from './factDataValue';
|
||||
import type { FactDataSummary } from './factDataSummary';
|
||||
import type { FactDataUnit } from './factDataUnit';
|
||||
import type { SourceType } from './sourceType';
|
||||
import type { FactDataSourceUrl } from './factDataSourceUrl';
|
||||
@ -21,6 +22,7 @@ export interface FactData {
|
||||
unit_id?: FactDataUnitId;
|
||||
key: string;
|
||||
value?: FactDataValue;
|
||||
summary?: FactDataSummary;
|
||||
unit?: FactDataUnit;
|
||||
source_type: SourceType;
|
||||
source_url?: FactDataSourceUrl;
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Web4Ai API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type FactDataSummary = string | null;
|
||||
@ -19,6 +19,7 @@ export * from './factData';
|
||||
export * from './factDataCollectedAt';
|
||||
export * from './factDataExpiresAt';
|
||||
export * from './factDataSourceUrl';
|
||||
export * from './factDataSummary';
|
||||
export * from './factDataUnit';
|
||||
export * from './factDataUnitId';
|
||||
export * from './factDataValue';
|
||||
|
||||
@ -44,6 +44,15 @@ export function introParagraph(_industryId: IndustryType, infoFields: InfoField[
|
||||
return collected?.value?.trim() ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 소개 문구 축약본. `intro`/`room_intro` fact 가 길 때만 백엔드가 채워 보낸다(요청·응답에만
|
||||
* 실리고 DB 에는 남지 않는다). 없으면 빈 값 — 부르는 쪽이 원문(introParagraph)으로 떨어진다.
|
||||
*/
|
||||
export function introSummary(_industryId: IndustryType, infoFields: InfoField[]): string {
|
||||
const collected = infoFields.find((f) => f.id === 'intro' || f.id === 'room_intro');
|
||||
return collected?.summary?.trim() ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 이용 규정으로 읽히는 fact 들. 순서가 곧 화면 순서다.
|
||||
*
|
||||
|
||||
@ -4,10 +4,13 @@
|
||||
*/
|
||||
import {EmptyStateNotice, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
|
||||
import type {SectionRenderProps} from '../../types';
|
||||
import {primaryPhoto, introParagraph} from '../common';
|
||||
import {primaryPhoto, introParagraph, introSummary} from '../common';
|
||||
|
||||
export function IntroSideBySide(props: SectionRenderProps) {
|
||||
const intro = props.section.body?.trim() || introParagraph(props.industryId, props.infoFields);
|
||||
const intro =
|
||||
props.section.body?.trim() ||
|
||||
introSummary(props.industryId, props.infoFields) ||
|
||||
introParagraph(props.industryId, props.infoFields);
|
||||
const {section, isSelected, onSelect, storeName, template, photos} = props;
|
||||
const shot = photos[1] ?? primaryPhoto(photos);
|
||||
// 사진이 없으면 회색 판만 남기고 <img> 는 렌더하지 않는다.
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -155,6 +155,8 @@ function factField(fact: FactData, spec?: FieldSpecData): InfoField {
|
||||
isVerified: publishable,
|
||||
source: factSource(fact),
|
||||
critical: spec?.critical,
|
||||
// 캔버스 미리보기용 축약문(intro/room_intro 가 길 때만 백엔드가 채워 보낸다).
|
||||
summary: fact.summary?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,9 @@ export interface InfoField {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
/** 캔버스 미리보기용 축약문. intro/room_intro 원문이 길 때만 백엔드가 채워 보낸다(응답 전용,
|
||||
* place_facts 에는 저장되지 않는다). 없으면 값이 짧거나 요약에 실패한 것 — 원문(value)으로 폴백한다. */
|
||||
summary?: string;
|
||||
/** 수집됐지만 사장님 확인이 필요한 값(백엔드 FactStatus.UNVERIFIED / PENDING_OWNER 대응). */
|
||||
requiresVerification: boolean;
|
||||
/** [맞아요] 또는 수정 승인을 거쳤는가(VERIFIED / CORRECTED 대응). */
|
||||
|
||||
@ -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;
|
||||
/**
|
||||
|
||||
@ -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 를 **통째로 다시 그린다** — 그때 히어로의 <p> 가 새 노드로 바뀐다.
|
||||
한 번 잡아 둔 참조로 계속 쓰면 그 순간부터 아무 일도 안 일어나면서 오류도 안 난다. */
|
||||
function findNode() {
|
||||
var wanted = [base];
|
||||
if (last) wanted.push(last);
|
||||
실패하면 #root 를 **통째로 다시 그린다** — 대표 문구 <p> 가 새 노드로 바뀌고 그 아래 붙여 둔
|
||||
줄은 같이 사라진다. 한 번 잡아 둔 참조로 계속 쓰면 그 순간부터 아무 일도 안 일어나면서 오류도 안 난다. */
|
||||
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);
|
||||
}
|
||||
|
||||
/* ══ ② 헤더 안 미니 플레이어 ═══════════════════════════════════════════════
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {primaryImage} from '@site/seo/meta';
|
||||
import {
|
||||
@ -97,7 +98,7 @@ export function Hero() {
|
||||
<div className="col-span-12 flex flex-col gap-5 px-4 py-8 sm:px-6 xl:col-span-4 xl:col-start-1 xl:row-start-1 xl:py-12 xl:pb-56 xl:pl-10 xl:pr-8">
|
||||
{tagline && (
|
||||
<p className="measure font-serif text-[length:var(--fs-lead)] leading-relaxed">
|
||||
{tagline}
|
||||
<HeroCatchphrase>{tagline}</HeroCatchphrase>
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import type {CSSProperties, ReactNode} from 'react';
|
||||
import {isoDate} from '@site/lib/format';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
@ -326,7 +327,7 @@ export function Shell({children}: {children: ReactNode}) {
|
||||
)}
|
||||
{slogan && (
|
||||
<p className="text-muted measure shrink-0 text-[length:var(--fs-xs)] leading-[1.9] sm:max-w-[18rem] sm:text-right">
|
||||
{slogan}
|
||||
<HeroCatchphrase>{slogan}</HeroCatchphrase>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice} from '@site/lib/derive';
|
||||
|
||||
@ -87,7 +88,7 @@ export function Hero() {
|
||||
|
||||
{line && (
|
||||
<p className="measure mt-4 text-[length:var(--fs-lead)] font-medium leading-relaxed opacity-80">
|
||||
{line}
|
||||
<HeroCatchphrase>{line}</HeroCatchphrase>
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingLabel, bookingLinks, unitSpec} from '@site/lib/derive';
|
||||
|
||||
@ -275,7 +276,7 @@ export function Hero() {
|
||||
// 이름이 다 선 뒤에 한 박자 쉬고 따라온다. 동시에 뜨면 두 줄이 한 덩어리로 보인다.
|
||||
style={{animationDelay: '0.3s'}}
|
||||
>
|
||||
{subline}
|
||||
<HeroCatchphrase>{subline}</HeroCatchphrase>
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import type {ReactNode} from 'react';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {
|
||||
@ -83,7 +84,7 @@ export function Shell({children}: {children: ReactNode}) {
|
||||
|
||||
{(narrative.tagline ?? narrative.heroSubline) && (
|
||||
<p className="text-muted mt-3 text-[length:var(--fs-sm)]" style={{lineHeight: 1.7}}>
|
||||
{narrative.tagline ?? narrative.heroSubline}
|
||||
<HeroCatchphrase>{narrative.tagline ?? narrative.heroSubline}</HeroCatchphrase>
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
12
solution/site/src/lib/lodging-catchphrases.json
Normal file
12
solution/site/src/lib/lodging-catchphrases.json
Normal file
@ -0,0 +1,12 @@
|
||||
[
|
||||
"잠시 걸음을 멈추고 온전한 쉼을 마주하는 시간",
|
||||
"바쁜 일상을 내려놓고 나를 위한 여유를 담아갑니다",
|
||||
"여행의 길목에서 만나는 조용하고 편안한 머무름",
|
||||
"아무것도 하지 않아도 편안해지는 오늘의 휴식",
|
||||
"낯선 곳에서 느끼는 다정한 온기와 작은 쉼표",
|
||||
"지친 하루의 끝에 찾아오는 나만의 따뜻한 여백",
|
||||
"천천히 흘러가는 시간 속에서 마주하는 깊은 휴식",
|
||||
"스쳐 가는 여행 속에서 오래 기억될 아늑한 순간",
|
||||
"복잡한 생각은 잠시 잊고 편안하게 쉬어가세요",
|
||||
"작은 쉼이 모여 내일을 살아갈 따뜻한 힘이 됩니다"
|
||||
]
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {Phone} from 'lucide-react';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, essentialRows, RULE_FACT_KEYS} from '@site/lib/derive';
|
||||
import {bookingActionLabel, bookingLinks, essentialRows} from '@site/lib/derive';
|
||||
import {formatKoreanDate} from '@site/lib/format';
|
||||
import type {InfoRow} from '@site/lib/derive';
|
||||
import {Section} from '@site/lib/ui';
|
||||
@ -43,19 +43,45 @@ const RULE_LABELS = new Set([
|
||||
export function EssentialInfoSection() {
|
||||
const payload = useSite();
|
||||
const rows = essentialRows(payload);
|
||||
const guides = payload.links.filter((link) => link.confirmed &&
|
||||
[link.stayGuide?.policy, link.stayGuide?.service, link.stayGuide?.reservation].some((text) => text?.trim()));
|
||||
const structured = guides.flatMap((link) => link.stayGuide?.fields ?? []);
|
||||
const mergedRows = [...rows];
|
||||
const seen = new Set(rows.map((row) => row.key));
|
||||
for (const field of structured) {
|
||||
// 직접 입력한 노출값을 우선한다. 같은 항목을 출처마다 반복하지 않는다.
|
||||
if (!seen.has(field.key)) {
|
||||
mergedRows.push(field);
|
||||
seen.add(field.key);
|
||||
}
|
||||
}
|
||||
const isRule = (row: InfoRow) => {
|
||||
if (['cooking_allowed', 'smoking'].includes(row.key ?? '')) return false;
|
||||
return RULE_LABELS.has(row.label) || structured.some((field) => field.key === row.key && field.group === 'rules');
|
||||
};
|
||||
const hiddenCount = payload.facts.filter(
|
||||
(fact) => fact.scope === 'place' && !rows.some((row) => row.label === fact.label),
|
||||
).length;
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
if (rows.length === 0 && guides.length === 0) return null;
|
||||
|
||||
/**
|
||||
* 규정과 나머지를 가른다.
|
||||
*
|
||||
* ★ 순서를 뒤집지 않는다 — 규정이 먼저다. 예약 전에 알아야 클레임이 안 난다.
|
||||
*/
|
||||
const ruleRows = rows.filter((row) => RULE_LABELS.has(row.label));
|
||||
const otherRows = rows.filter((row) => !RULE_LABELS.has(row.label));
|
||||
const ruleRows = [
|
||||
...mergedRows.filter(isRule),
|
||||
...guides.filter((link) => !link.stayGuide?.fields?.length && link.stayGuide?.policy).map((link) => ({
|
||||
label: 'NOL 이용 안내', value: link.stayGuide?.policy ?? '',
|
||||
})),
|
||||
];
|
||||
const otherRows = [
|
||||
...mergedRows.filter((row) => !isRule(row)),
|
||||
...guides.filter((link) => !link.stayGuide?.fields?.length && link.stayGuide?.service).map((link) => ({
|
||||
label: 'NOL 시설 안내', value: link.stayGuide?.service ?? '',
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<Section
|
||||
@ -71,7 +97,7 @@ export function EssentialInfoSection() {
|
||||
<span>
|
||||
{hiddenCount > 0
|
||||
? `확인 중인 항목 ${hiddenCount}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.`
|
||||
: '모든 항목이 사업자 확인을 거쳤습니다.'}
|
||||
: '등록 정보와 수집한 안내를 기준으로 표시합니다.'}
|
||||
</span>
|
||||
<span className="font-medium">{formatKoreanDate(payload.site.updatedAt)} 기준</span>
|
||||
</span>
|
||||
@ -80,6 +106,11 @@ export function EssentialInfoSection() {
|
||||
<div className="space-y-8">
|
||||
{ruleRows.length > 0 && <Rows title="예약 전 확인" rows={ruleRows} emphasis />}
|
||||
{otherRows.length > 0 && <Rows title="시설 · 편의" rows={otherRows} />}
|
||||
{guides.filter((link) => link.stayGuide?.reservation).map((link) => (
|
||||
<Rows key={`reservation-${link.url}`} title="예약 공지" emphasis rows={[
|
||||
{label: 'NOL 예약 공지', value: link.stayGuide?.reservation ?? ''},
|
||||
]} />
|
||||
))}
|
||||
<BookingRow />
|
||||
</div>
|
||||
</Section>
|
||||
@ -112,16 +143,16 @@ function Rows({title, rows, emphasis}: {title: string; rows: InfoRow[]; emphasis
|
||||
|
||||
{/* ★ 테두리는 판 하나에만 준다. 줄 사이는 선(divide)이지 상자가 아니다. */}
|
||||
<dl className="divide-line divide-y">
|
||||
{rows.map((row) => (
|
||||
{rows.map((row, index) => (
|
||||
<div
|
||||
key={row.label}
|
||||
key={`${row.label}-${index}`}
|
||||
className="flex flex-col gap-1 py-3.5 sm:flex-row sm:items-baseline sm:gap-6"
|
||||
>
|
||||
<dt className="text-muted text-[length:var(--fs-sm)] sm:w-40 sm:shrink-0">
|
||||
{row.label}
|
||||
</dt>
|
||||
{/* measure: 긴 규정 문장이 화면 끝까지 늘어나면 다음 줄 첫 글자를 못 찾는다. */}
|
||||
<dd className="measure text-[length:var(--fs-sm)] font-semibold">
|
||||
<dd className="measure min-w-0 whitespace-pre-line break-words text-[length:var(--fs-sm)] font-semibold">
|
||||
{row.value}
|
||||
{row.note && <span className="text-muted mt-0.5 block font-normal">{row.note}</span>}
|
||||
</dd>
|
||||
|
||||
34
solution/site/src/sections/HeroCatchphrase.tsx
Normal file
34
solution/site/src/sections/HeroCatchphrase.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import {useEffect, useState, type CSSProperties} from 'react';
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import lines from '@site/lib/lodging-catchphrases.json';
|
||||
import './hero-catchphrase.css';
|
||||
|
||||
export function HeroCatchphrase({children}: {children?: string}) {
|
||||
const {place} = useSite();
|
||||
const lodging = place.category === PlaceCategory.LODGING;
|
||||
const [index, setIndex] = useState(0);
|
||||
// 업장 소개는 첫 줄에 고정하고, 공통 감성 문구만 둘째 줄에서 순환한다.
|
||||
const phrases = lines;
|
||||
useEffect(() => {
|
||||
if (!lodging) return;
|
||||
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const timer = window.setInterval(() => {
|
||||
if (!document.hidden && !motion.matches) setIndex((value) => (value + 1) % phrases.length);
|
||||
}, 6000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [lodging, phrases.length]);
|
||||
|
||||
if (!lodging) return children;
|
||||
const text = phrases[index % phrases.length];
|
||||
return (
|
||||
<span className="hero-catchphrase">
|
||||
{children && <span className="hero-catchphrase-fixed">{children}</span>}
|
||||
<span className="w4d-line w4d-line--in" key={index}>
|
||||
{text.split(/\s+/).map((word, i) => (
|
||||
<span key={i}>{i > 0 && ' '}<span className="w4d-w"><span style={{'--w4d-d': `${i * 55}ms`} as CSSProperties}>{word}</span></span></span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {ChevronLeft, ChevronRight, Phone} from 'lucide-react';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
@ -124,7 +125,7 @@ export function HeroPension() {
|
||||
</h1>
|
||||
{(narrative.tagline ?? narrative.heroSubline) && (
|
||||
<p className="measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-90">
|
||||
{narrative.tagline ?? narrative.heroSubline}
|
||||
<HeroCatchphrase>{narrative.tagline ?? narrative.heroSubline}</HeroCatchphrase>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {ChevronDown, MapPin} from 'lucide-react';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {primaryImage} from '@site/seo/meta';
|
||||
@ -99,7 +100,7 @@ export function HeroSection() {
|
||||
{/* ★ 한 줄이 없으면 요약이라도 세운다 — 이름만 덩그러니 있는 첫 화면은 안내가 아니다. */}
|
||||
{(narrative.tagline ?? narrative.heroSubline ?? narrative.summary) && (
|
||||
<p className="measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-85">
|
||||
{narrative.tagline ?? narrative.heroSubline ?? narrative.summary}
|
||||
<HeroCatchphrase>{narrative.tagline ?? narrative.heroSubline ?? narrative.summary}</HeroCatchphrase>
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {Phone} from 'lucide-react';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice, placeRow} from '@site/lib/derive';
|
||||
@ -59,7 +60,7 @@ export function HeroSplit() {
|
||||
{place.name}
|
||||
</h1>
|
||||
<p className="measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-80">
|
||||
{narrative.tagline ?? narrative.heroSubline ?? narrative.summary}
|
||||
<HeroCatchphrase>{narrative.tagline ?? narrative.heroSubline ?? narrative.summary}</HeroCatchphrase>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -47,6 +47,16 @@ function meters(text: string | undefined): number {
|
||||
return Number(m[1]) * (m[2].toLowerCase() === 'km' ? 1000 : 1);
|
||||
}
|
||||
|
||||
function distanceOf(place: LocalPlace): number {
|
||||
// 숫자가 없는 옛 payload만 표기 문자열로 복원한다.
|
||||
if (place.distanceMeters != null) {
|
||||
return Number.isFinite(place.distanceMeters) && place.distanceMeters >= 0
|
||||
? place.distanceMeters
|
||||
: Infinity;
|
||||
}
|
||||
return meters(place.distanceText);
|
||||
}
|
||||
|
||||
/**
|
||||
* 주변 정보.
|
||||
*
|
||||
@ -68,11 +78,11 @@ export function LocalGuideSection() {
|
||||
|
||||
const all = [...local.restaurants, ...local.attractions];
|
||||
const count = (r: (typeof RANGES)[number]) =>
|
||||
all.filter((place) => r.test(meters(place.distanceText))).length;
|
||||
all.filter((place) => r.test(distanceOf(place))).length;
|
||||
// 비었거나 전체와 똑같은 탭은 세우지 않는다 — 눌러도 그대로인 탭은 고장으로 읽힌다.
|
||||
const ranges = RANGES.filter((r, i) => i === 0 || (count(r) > 0 && count(r) < all.length));
|
||||
const active = ranges.find((r) => r.id === range) ?? RANGES[0];
|
||||
const within = (place: LocalPlace) => active.test(meters(place.distanceText));
|
||||
const within = (place: LocalPlace) => active.test(distanceOf(place));
|
||||
|
||||
return (
|
||||
<Section
|
||||
@ -201,7 +211,7 @@ function PlaceList({
|
||||
)}
|
||||
{/* 거리는 사진 위에 얹는다 — 손님이 카드에서 제일 먼저 찾는 값이다.
|
||||
★ 분을 앞에, 거리를 뒤에. 답이 '분'이고 'm' 는 그 근거다. */}
|
||||
{place.distanceText && (
|
||||
{(Number.isFinite(distanceOf(place)) || place.distanceText) && (
|
||||
<span
|
||||
className="absolute bottom-2 left-2 rounded-md px-2 py-0.5 text-[length:var(--fs-xs)] font-bold"
|
||||
style={{
|
||||
@ -209,7 +219,7 @@ function PlaceList({
|
||||
color: 'var(--tpl-bg, #fff)',
|
||||
}}
|
||||
>
|
||||
{walkText(meters(place.distanceText)) ?? place.distanceText}
|
||||
{walkText(distanceOf(place)) ?? place.distanceText}
|
||||
<span className="ml-1 font-normal opacity-70">{place.distanceText}</span>
|
||||
</span>
|
||||
)}
|
||||
@ -217,6 +227,9 @@ function PlaceList({
|
||||
|
||||
<span className="flex flex-1 flex-col gap-1 p-3.5">
|
||||
<span className="text-[length:var(--fs-sm)] font-bold">{place.name}</span>
|
||||
{place.location && (
|
||||
<span className="text-muted text-[length:var(--fs-xs)] break-words">{place.location}</span>
|
||||
)}
|
||||
{place.description && (
|
||||
<span className="text-muted line-clamp-3 text-[length:var(--fs-xs)] leading-relaxed">
|
||||
{place.description}
|
||||
@ -245,10 +258,11 @@ function PlaceList({
|
||||
<span>
|
||||
{' '}
|
||||
{place.distanceText}
|
||||
{walkText(meters(place.distanceText)) && ` · ${walkText(meters(place.distanceText))}`}
|
||||
{walkText(distanceOf(place)) && ` · ${walkText(distanceOf(place))}`}
|
||||
</span>
|
||||
)}
|
||||
{place.description && <p>{place.description}</p>}
|
||||
{place.location && <p>{place.location}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
12
solution/site/src/sections/hero-catchphrase.css
Normal file
12
solution/site/src/sections/hero-catchphrase.css
Normal file
@ -0,0 +1,12 @@
|
||||
.hero-catchphrase { display: block; }
|
||||
.hero-catchphrase-fixed { display: block; }
|
||||
.hero-catchphrase .w4d-line { display: block; margin-top: 0.5rem; min-height: 1.5em; word-break: keep-all; }
|
||||
.hero-catchphrase .w4d-w { display: inline-block; overflow: hidden; vertical-align: bottom; }
|
||||
.hero-catchphrase .w4d-w > span { display: inline-block; animation: lodging-word-in 0.45s both; animation-delay: var(--w4d-d); }
|
||||
@keyframes lodging-word-in {
|
||||
from { opacity: 0; transform: translateY(100%); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-catchphrase .w4d-w > span { animation: none; }
|
||||
}
|
||||
27
solution/site/src/sections/hero-catchphrase.test.tsx
Normal file
27
solution/site/src/sections/hero-catchphrase.test.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import {expect, it} from 'vitest';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {SiteProvider} from '@site/lib/site-context';
|
||||
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
|
||||
import lines from '@site/lib/lodging-catchphrases.json';
|
||||
import {HeroCatchphrase} from './HeroCatchphrase';
|
||||
|
||||
it('keeps the original lodging tagline in the static HTML', () => {
|
||||
const html = renderToStaticMarkup(<SiteProvider payload={MOONLIGHT_STAY_PAYLOAD}><HeroCatchphrase>고유문구</HeroCatchphrase></SiteProvider>);
|
||||
expect(html).toContain('<span class="hero-catchphrase-fixed">고유문구</span>');
|
||||
expect(html).toContain('w4d-line--in');
|
||||
const text = html.replace(/<[^>]+>/g, '');
|
||||
expect(text).toContain('고유문구' + lines[0]);
|
||||
});
|
||||
|
||||
it('does not add lodging copy or controls to another category', () => {
|
||||
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
|
||||
payload.place.category = PlaceCategory.CAFE;
|
||||
const html = renderToStaticMarkup(<SiteProvider payload={payload}><HeroCatchphrase>카페 소개</HeroCatchphrase></SiteProvider>);
|
||||
expect(html).toBe('카페 소개');
|
||||
});
|
||||
|
||||
it('ships ten distinct short phrases', () => {
|
||||
expect(new Set(lines).size).toBe(10);
|
||||
expect(lines.every((line) => line.length >= 15 && line.length <= 30)).toBe(true);
|
||||
});
|
||||
45
solution/site/src/sections/intro-summary.test.tsx
Normal file
45
solution/site/src/sections/intro-summary.test.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import {expect, it} from 'vitest';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {FactStatus, SourceType, type SitePayload} from '@o2o/shared';
|
||||
import {SiteProvider} from '@site/lib/site-context';
|
||||
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
|
||||
import {AnswerBlock} from './AnswerBlock';
|
||||
import {EssentialInfoSection} from './EssentialInfoSection';
|
||||
import {AboutSection} from './AboutSection';
|
||||
|
||||
function fixture(summary?: string): SitePayload {
|
||||
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
|
||||
payload.facts = [{
|
||||
key: 'intro', label: '숙소 소개', value: '숙소 소개 전체 원문입니다.', summary,
|
||||
scope: 'place', type: 'text', status: FactStatus.VERIFIED,
|
||||
sourceType: SourceType.LLM, critical: false, required: false,
|
||||
}];
|
||||
payload.narrative = {about: ['숙소 소개 전체 원문입니다.'], summary: '기존 첫 문장'};
|
||||
return payload;
|
||||
}
|
||||
|
||||
it('shows the same summary in both booking information sections', () => {
|
||||
const payload = fixture('짧게 요약한 숙소 소개');
|
||||
for (const section of [<AnswerBlock />, <EssentialInfoSection />]) {
|
||||
const html = renderToStaticMarkup(<SiteProvider payload={payload}>{section}</SiteProvider>);
|
||||
expect(html).toContain('짧게 요약한 숙소 소개');
|
||||
expect(html).not.toContain('숙소 소개 전체 원문입니다.');
|
||||
}
|
||||
const about = renderToStaticMarkup(<SiteProvider payload={payload}><AboutSection /></SiteProvider>);
|
||||
expect(about).toContain('숙소 소개 전체 원문입니다.');
|
||||
});
|
||||
|
||||
it.each([undefined, ' '])('keeps original text when summary is %s', (summary) => {
|
||||
const payload = fixture(summary);
|
||||
const html = renderToStaticMarkup(<SiteProvider payload={payload}><EssentialInfoSection /></SiteProvider>);
|
||||
expect(html).toContain('숙소 소개 전체 원문입니다.');
|
||||
});
|
||||
|
||||
it('does not expose summaries from unverified facts', () => {
|
||||
const payload = fixture('검증 안 된 요약');
|
||||
payload.facts[0].status = FactStatus.UNVERIFIED;
|
||||
for (const section of [<AnswerBlock />, <EssentialInfoSection />]) {
|
||||
const html = renderToStaticMarkup(<SiteProvider payload={payload}>{section}</SiteProvider>);
|
||||
expect(html).not.toContain('검증 안 된 요약');
|
||||
}
|
||||
});
|
||||
@ -15,7 +15,6 @@ import {useState} from 'react';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@site/lib/derive';
|
||||
import {SongsSection} from './SongsSection';
|
||||
import {DailySection} from './DailySection';
|
||||
import {PeopleSection} from './PeopleSection';
|
||||
import {ChronicleSection} from './ChronicleSection';
|
||||
import {PostcardSection} from './PostcardSection';
|
||||
@ -30,7 +29,6 @@ const COUNT_WORD: Record<number, string> = {1: '한', 2: '두', 3: '세', 4: '
|
||||
|
||||
const TABS = [
|
||||
{id: 'songs', label: '가요 다방', Component: SongsSection},
|
||||
{id: 'daily', label: '오늘의 한 장', Component: DailySection},
|
||||
{id: 'people', label: '인물 열전', Component: PeopleSection},
|
||||
{id: 'chronicle', label: '시간의 골목', Component: ChronicleSection},
|
||||
{id: 'postcard', label: '오늘의 엽서', Component: PostcardSection},
|
||||
@ -38,6 +36,7 @@ const TABS = [
|
||||
|
||||
export function StorySection() {
|
||||
const payload = useSite();
|
||||
const title = sectionName(payload, 'story', `${payload.place.addressLocality ?? '지역'} 이야기`);
|
||||
// 데이터가 있는 것만 탭이 된다 — 눌러서 빈 화면을 보게 하지 않는다.
|
||||
const tabs = TABS.filter((tab) => sectionItems(payload, tab.id).items.length > 0);
|
||||
const [active, setActive] = useState(0);
|
||||
@ -65,13 +64,13 @@ export function StorySection() {
|
||||
>
|
||||
<div className="shell">
|
||||
<h2 id="story-heading" className="h2">
|
||||
{sectionName(payload, 'story', '군산 이야기')}
|
||||
{title}
|
||||
</h2>
|
||||
<p className="measure mt-3 text-[length:var(--fs-sm)] opacity-70">
|
||||
이 도시를 {COUNT_WORD[tabs.length] ?? `${tabs.length}`} 갈래로 봅니다. 하나씩 골라 보세요.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-1.5" role="tablist" aria-label="군산 이야기">
|
||||
<div className="mt-6 flex flex-wrap gap-1.5" role="tablist" aria-label={title}>
|
||||
{tabs.map((tab, index) => {
|
||||
const on = index === active;
|
||||
return (
|
||||
|
||||
21
solution/site/src/sections/local-guide.test.tsx
Normal file
21
solution/site/src/sections/local-guide.test.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import {expect, it} from 'vitest';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {SiteProvider} from '@site/lib/site-context';
|
||||
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
|
||||
import {LocalGuideSection} from './LocalGuideSection';
|
||||
|
||||
it('renders collected descriptions and addresses with numeric distance filters', () => {
|
||||
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
|
||||
payload.local.attractions = [];
|
||||
payload.local.restaurants = [
|
||||
{name: 'Cafe A', category: 'cafe', searchQuery: 'Cafe A', location: 'Address A',
|
||||
description: 'Collected description', distanceMeters: 401, distanceText: '400m'},
|
||||
{name: 'Cafe B', category: 'cafe', searchQuery: 'Cafe B', distanceMeters: 100},
|
||||
{name: 'Cafe C', category: 'cafe', searchQuery: 'Cafe C'},
|
||||
];
|
||||
const html = renderToStaticMarkup(<SiteProvider payload={payload}><LocalGuideSection /></SiteProvider>);
|
||||
expect(html).toContain('Collected description');
|
||||
expect(html).toContain('Address A');
|
||||
expect(html).toMatch(/걸어서 5분 이내 <span[^>]*>1<\/span>/);
|
||||
expect(html).toContain('Cafe C');
|
||||
});
|
||||
71
solution/site/src/sections/stay-guide.test.tsx
Normal file
71
solution/site/src/sections/stay-guide.test.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import {expect, it} from 'vitest';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {sanitizePayloadForPublish, FactStatus, SourceType} from '@o2o/shared';
|
||||
import {SiteProvider} from '@site/lib/site-context';
|
||||
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
|
||||
import {EssentialInfoSection} from './EssentialInfoSection';
|
||||
|
||||
function fixture(confirmed = true) {
|
||||
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
|
||||
payload.facts = [];
|
||||
payload.links = [{
|
||||
channel: 1, title: 'NOL', url: 'https://nol.yanolja.com/stay/domestic/10068088', confirmed,
|
||||
stayGuide: {
|
||||
policy: '체크인 15:00 체크아웃 11:00\n전 연령 동일 1인당 2만원',
|
||||
service: '주방\n욕조',
|
||||
reservation: '- 반려동물 입실금지\n<script>alert(1)</script>',
|
||||
},
|
||||
}];
|
||||
return payload;
|
||||
}
|
||||
|
||||
it('renders guides without facts, before booking actions, preserving line breaks and escaping HTML', () => {
|
||||
const payload = fixture();
|
||||
const html = renderToStaticMarkup(<SiteProvider payload={payload}><EssentialInfoSection /></SiteProvider>);
|
||||
expect(html).toContain('예약 전 확인');
|
||||
expect(html).toContain('체크인 15:00 체크아웃 11:00\n전 연령 동일 1인당 2만원');
|
||||
expect(html).toContain('주방\n욕조');
|
||||
expect(html).toContain('반려동물 입실금지');
|
||||
expect(html).toContain('whitespace-pre-line');
|
||||
expect(html).toContain('<script>');
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).not.toContain('모든 항목이 사업자 확인');
|
||||
expect(html.indexOf('반려동물 입실금지')).toBeLessThan(html.indexOf('예약은 아래로'));
|
||||
});
|
||||
|
||||
it('does not render or embed guides from unconfirmed links', () => {
|
||||
const payload = fixture(false);
|
||||
const html = renderToStaticMarkup(<SiteProvider payload={payload}><EssentialInfoSection /></SiteProvider>);
|
||||
expect(html).toBe('');
|
||||
expect(JSON.stringify(sanitizePayloadForPublish(payload))).not.toContain('반려동물 입실금지');
|
||||
});
|
||||
|
||||
it('supports older payloads without guides', () => {
|
||||
const payload = fixture();
|
||||
delete payload.links[0].stayGuide;
|
||||
expect(renderToStaticMarkup(<SiteProvider payload={payload}><EssentialInfoSection /></SiteProvider>)).toBe('');
|
||||
});
|
||||
|
||||
it('keeps structured rows and restrictions without the original disclosure or source link', () => {
|
||||
const payload = fixture();
|
||||
payload.facts = [{key: 'check_in_time', label: '체크인 시간', value: '16:00',
|
||||
scope: 'place', type: 'time', status: FactStatus.CORRECTED, sourceType: SourceType.OWNER,
|
||||
critical: true, required: true}];
|
||||
payload.links[0].stayGuide!.fields = [
|
||||
{key: 'check_in_time', label: '체크인 시간', value: '15:00', group: 'rules'},
|
||||
{key: 'extra_person_fee', label: '인원 추가 요금', value: '20,000원', note: '1인당 · 전 연령 동일', group: 'rules'},
|
||||
{key: 'cooking_allowed', label: '취사', value: '가능', note: '생선구이 조리금지', group: 'facilities'},
|
||||
];
|
||||
const html = renderToStaticMarkup(<SiteProvider payload={payload}><EssentialInfoSection /></SiteProvider>);
|
||||
expect(html.match(/체크인 시간/g)).toHaveLength(1);
|
||||
expect(html).toContain('16:00');
|
||||
expect(html).toContain('20,000원');
|
||||
expect(html).toContain('1인당 · 전 연령 동일');
|
||||
expect(html).toContain('생선구이 조리금지');
|
||||
expect(html).not.toContain('<details');
|
||||
expect(html).not.toContain('NOL 이용안내 · 시설 원문');
|
||||
expect(html).not.toContain('NOL 안내 원문');
|
||||
expect(html).not.toContain('체크인 15:00 체크아웃 11:00');
|
||||
expect(html).toContain('반려동물 입실금지');
|
||||
expect(html).toContain('예약은 아래로');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user