[feat] solution: 날씨 조건 세분화,
공식채널 단일화, 한일옥 거리 반영 날씨 조건을 7종으로 세분화, 축제 종료 여부와 무관하게 상시 노출, '지역 읽기'갈래 축소, 야놀자(NOL) 브랜드명 제거.
This commit is contained in:
parent
29af37f158
commit
1f26bc7065
@ -3,13 +3,70 @@
|
||||
`Open-Meteo → /v1/local/weather → useLiveWeather → WeatherSection`
|
||||
|
||||
관측값은 기존 API를 사용하며 브라우저에서 10분마다 갱신한다. 조회 실패 시 마지막 관측값과
|
||||
관측 시각을 유지한다. 날씨 문구는 API 요청마다 생성하지 않는다.
|
||||
관측 시각을 유지한다. 날씨 문구는 API 요청마다 생성하지 않는다. **API 키 없음** — Open-Meteo
|
||||
는 키 발급 없이 쓰는 무료 공개 엔드포인트다(`services/external/open_meteo.py`).
|
||||
|
||||
## Open-Meteo 응답 → 내부 스냅샷
|
||||
|
||||
`GET https://api.open-meteo.com/v1/forecast?latitude=&longitude=¤t=temperature_2m,weather_code,wind_speed_10m&timezone=auto`
|
||||
|
||||
원본 `current` 블록(`temperature_2m`·`weather_code`·`wind_speed_10m`·`time`)을 어댑터가
|
||||
`{temperature, weather_code, wind_speed, observed_at, timezone, latitude, longitude}`로
|
||||
정규화한다(`open_meteo.py:fetch_current_weather`). `weather_code`는 WMO 표준 정수 코드 그대로
|
||||
저장·전달되고, 하늘 상태 문구로 바꾸는 건 아래 두 곳뿐이다 — **반드시 같은 구간이어야 한다**
|
||||
(하이드레이션 전엔 백엔드 값, 후엔 브라우저 값을 쓰는데 표가 다르면 같은 날씨인데 문구가 바뀐다):
|
||||
|
||||
- 서버: `site_payload._weather_condition()` — 프리렌더 스냅샷에 쓰인다.
|
||||
- 브라우저: `use-live-weather.ts:condition()` — 10분마다 재조회할 때 쓰인다.
|
||||
|
||||
| 코드 | 날씨 상태(한국어) | 날씨 상태(영어) | 내부 분류 |
|
||||
|---|---|---|---|
|
||||
| 0 | 맑음 | Clear sky | 맑음 |
|
||||
| 1 | 대체로 맑음 | Mainly clear | 구름많음 |
|
||||
| 2 | 구름 조금 | Partly cloudy | 구름많음 |
|
||||
| 3 | 흐림 | Overcast | 구름많음 |
|
||||
| 45 | 안개 | Fog | 안개 |
|
||||
| 48 | 착빙성 안개 (서리 안개) | Depositing rime fog | 안개 |
|
||||
| 51 | 가벼운 이슬비 | Drizzle: Light intensity | 이슬비 |
|
||||
| 53 | 보통 이슬비 | Drizzle: Moderate intensity | 이슬비 |
|
||||
| 55 | 강한 이슬비 | Drizzle: Dense intensity | 이슬비 |
|
||||
| 56 | 가벼운 착빙성 이슬비 | Freezing Drizzle: Light intensity | 어는비 |
|
||||
| 57 | 강한 착빙성 이슬비 | Freezing Drizzle: Dense intensity | 어는비 |
|
||||
| 61 | 약한 비 | Rain: Slight intensity | 비 |
|
||||
| 63 | 보통 비 | Rain: Moderate intensity | 비 |
|
||||
| 65 | 강한 비 | Rain: Heavy intensity | 강한비 |
|
||||
| 66 | 약한 착빙성 비 (얼어붙는 비) | Freezing Rain: Light intensity | 어는비 |
|
||||
| 67 | 강한 착빙성 비 | Freezing Rain: Heavy intensity | 어는비 |
|
||||
| 71 | 약한 눈 | Snow fall: Slight intensity | 눈 |
|
||||
| 73 | 보통 눈 | Snow fall: Moderate intensity | 눈 |
|
||||
| 75 | 강한 눈 | Snow fall: Heavy intensity | 눈 |
|
||||
| 77 | 싸라기눈 (진눈깨비) | Snow grains | 눈 |
|
||||
| 80 | 약한 소나기 | Rain showers: Slight | 소나기 |
|
||||
| 81 | 보통 소나기 | Rain showers: Moderate | 소나기 |
|
||||
| 82 | 강한 소나기 | Rain showers: Violent | 소나기 |
|
||||
| 85 | 약한 소나기성 눈 | Snow showers: Slight | 소나기 |
|
||||
| 86 | 강한 소나기성 눈 | Snow showers: Heavy | 소나기 |
|
||||
| 95 | 뇌우 (천둥번개) | Thunderstorm: Slight or moderate | 뇌우 |
|
||||
| 96 | 우박을 동반한 약한 뇌우 | Thunderstorm with slight hail | 뇌우 |
|
||||
| 99 | 우박을 동반한 강한 뇌우 | Thunderstorm with heavy hail | 뇌우 |
|
||||
|
||||
이 27개가 Open-Meteo `weather_code`의 전체 정의 값이다 — 표에 없는 값(파싱 실패 포함)만
|
||||
안전하게 `흐림`으로 떨어진다(실제로는 도달하지 않는 방어 분기).
|
||||
|
||||
`weatherMood()`(`derive.ts`)는 위 11종을 화면 배경 그림용으로 다시 4종(맑음/흐림/비/눈)으로
|
||||
뭉친다 — 정규식 기반이라 새 분류를 추가해도 대개 자동으로 걸린다(예: "이슬비"·"강한비"·"어는비"는
|
||||
모두 `/비|우/` 패턴에 "비"가 들어 있어 `비`로 걸린다). `WeatherSection`의 `skyKey`는 노트가
|
||||
실제로 있는 세부 조건(구름많음/안개/소나기/뇌우/이슬비/강한비/어는비)이면 그 조건 그대로 쓰고,
|
||||
그 외(맑음/흐림/비/눈)엔 `mood`로 내려간다 — 어차피 세부 조건과 mood 값이 같기 때문이다.
|
||||
|
||||
`weather_notes.json → weather_notes.py → site_payload._weather → noteSets/tempNoteSets`
|
||||
|
||||
하늘 5종·기온 5구간에 각 5문구를 싣는다. 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고,
|
||||
브라우저에서는 무작위 시작 후 20초마다 한 바퀴 안에서 중복 없이 순환한다.
|
||||
기온 구간은 기존 `weatherBand`의 30·25·20·10도다. 구름많음은 그림상 흐림과 같지만 문구는 별도다.
|
||||
하늘 11종(맑음·구름많음·안개·비·이슬비·강한비·어는비·눈·소나기·뇌우·흐림)·기온 5구간에 각
|
||||
5문구를 싣는다. 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고, 브라우저에서는 무작위 시작
|
||||
후 20초마다 한 바퀴 안에서 중복 없이 순환한다. 기온 구간은 기존 `weatherBand`의 30·25·20·10도다.
|
||||
구름많음·안개·소나기·뇌우·이슬비·강한비·어는비는 그림(mood)상 흐림 또는 비와 같이 묶이지만
|
||||
문구는 조건별로 따로 쓴다. **비 중에서도 어는비(착빙성)만 안전 안내가 다르다** — 도로·보행
|
||||
결빙 위험을 짚는 문구가 필요해서 이슬비·강한비와 갈랐다(2026-09-17).
|
||||
|
||||
목업 README 2.3의 순환 계약을 제품으로 옮겼다. 군산 전용 시설·장소를 다른 사업장에 복사하지
|
||||
않도록 기본 문구는 장소·시설·영업시간을 주장하지 않는 공통 안내로 구성한다. **LLM 생성이 아니다.**
|
||||
|
||||
@ -1,7 +1,24 @@
|
||||
"""군산 공통 맛집 한일옥 등록. 기본은 조회, --apply로 현재 설정 DB에 반영한다.
|
||||
|
||||
external_id가 없는 지역 공통 항목은 snapshot이 모든 군산 업장에 포함한다.
|
||||
네이버 ID는 body에 보관하여 자동 수집의 (source, external_id) 갱신과 분리한다.
|
||||
네이버 ID는 body에 보관하여 자동 수집(NAVER_CRAWL)의 (source, external_id) 갱신과는 분리한다
|
||||
— source 가 OFFICIAL_WEB 으로 다르므로 자동 크롤링이 이 행을 건드리지 않는다.
|
||||
|
||||
★ 거리(2026-09-17 추가): 처음엔 external_id 없이 "지역 공통"(모든 군산 업장에 거리 없이 노출)
|
||||
으로만 등록했다. 하지만 한일옥은 실존 업소라 업장마다 실제 거리가 다르고, 지역 공통 캐시
|
||||
경로(services/snapshot.py::_local_contents, external_id IS NULL)는 거리를 업장마다 못 담는
|
||||
설계라 거리가 안 나갔다(2026-09-17 확인). 그래서 external_id 를 네이버 place id 로 채워
|
||||
그 경로에서 빠지게 하고, TourAPI·NAVER_CRAWL 맛집과 같은 개인화 경로(place_area_refs +
|
||||
site_sections, services/local_restaurant_enrichment.py 와 동일한 패턴)로 업장별 거리를 얹는다.
|
||||
★ 대가: 더는 "새 군산 업장에 자동으로 붙는" 지역 공통이 아니다 — 새 업장이 생기면
|
||||
이 스크립트를 다시 돌려야 그 업장에도 한일옥이 연결된다.
|
||||
|
||||
사용법 (solution/backend 에서, 가상환경 안에서) — 호스트(Windows) 실행은 PGSSLMODE=disable 필수
|
||||
(한글 홈 경로 탓에 asyncpg 인증서 로딩이 깨진다, dev-env-quirks 메모):
|
||||
PowerShell: $env:PGSSLMODE = "disable"; python scripts/pin_gunsan_hanilok.py [--apply]
|
||||
|
||||
배포서버 실행방법
|
||||
docker compose exec solution-backend python scripts/pin_gunsan_hanilok.py # 드라이런 먼저
|
||||
docker compose exec solution-backend python scripts/pin_gunsan_hanilok.py --apply # 반영
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
@ -20,18 +37,47 @@ 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.database.model.models import area_contents, place_area_refs, places
|
||||
from common.enums import LocalContentStatus, LocalContentType, LocalSource
|
||||
from common.utils.geo import haversine_m
|
||||
from config.server_configs import main_db_config as cfg
|
||||
from services.collector.naver_place_adapter import NaverPlaceAdapter
|
||||
from services.external import naver_place_lookup
|
||||
from services.local_content_service import LocalContentService
|
||||
from services.site_payload import _local
|
||||
from services.snapshot import _local_contents
|
||||
from services.snapshot import _local_contents, _site_places
|
||||
|
||||
REGION = '52군산시'
|
||||
NAVER_ID = '11861452'
|
||||
CONTENT_ID = uuid.uuid5(uuid.NAMESPACE_URL, 'web4ai:52군산시:restaurant:11861452')
|
||||
|
||||
|
||||
def _as_float(value) -> float | None:
|
||||
try:
|
||||
return float(value) if value is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_coordinates() -> tuple[float, float] | None:
|
||||
"""한일옥 좌표. TourAPI 에 없는 수기 등록이라 네이버 상세 페이지에서 가져온다
|
||||
(services/local_restaurant_enrichment.py 가 자동 크롤링 맛집에 쓰는 것과 같은 어댑터)."""
|
||||
summary = await NaverPlaceAdapter().fetch_summary(naver_place_lookup.place_url(NAVER_ID))
|
||||
if not summary:
|
||||
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 lat, lng
|
||||
|
||||
|
||||
async def main(apply: bool):
|
||||
lat_lng = await _fetch_coordinates()
|
||||
if lat_lng is None:
|
||||
print(json.dumps({'error': '네이버에서 한일옥 좌표를 가져오지 못했습니다.'}, ensure_ascii=False))
|
||||
return
|
||||
lat, lng = lat_lng
|
||||
|
||||
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,
|
||||
@ -41,8 +87,8 @@ async def main(apply: bool):
|
||||
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.region_code == REGION, area_contents.kind == 'restaurant',
|
||||
area_contents.source == LocalSource.OFFICIAL_WEB.value,
|
||||
area_contents.deleted.is_(False),
|
||||
))).mappings().all()
|
||||
if rows and any(r['body'].get('naverPlaceId') != NAVER_ID for r in rows):
|
||||
@ -53,12 +99,13 @@ async def main(apply: bool):
|
||||
local_content_id=content_id, region_code=REGION,
|
||||
content_type=LocalContentType.RESTAURANT.value, kind='restaurant',
|
||||
# 사용자 확인을 거친 수기 웹 등록. 크롤링한 값으로 표시하지 않는다.
|
||||
source=LocalSource.OFFICIAL_WEB.value, external_id=None, title='한일옥',
|
||||
source=LocalSource.OFFICIAL_WEB.value, external_id=NAVER_ID, 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'},
|
||||
latitude=lat, longitude=lng,
|
||||
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,
|
||||
@ -68,21 +115,72 @@ async def main(apply: bool):
|
||||
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(
|
||||
|
||||
targets = (await conn.execute(select(
|
||||
places.place_id, places.name, places.latitude, places.longitude,
|
||||
).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))
|
||||
|
||||
distances: dict[str, int | None] = {}
|
||||
for row in targets:
|
||||
plat, plng = _as_float(row['latitude']), _as_float(row['longitude'])
|
||||
distances[str(row['place_id'])] = (
|
||||
round(haversine_m(plat, plng, lat, lng)) if plat is not None and plng is not None else None
|
||||
)
|
||||
|
||||
if apply:
|
||||
for row in targets:
|
||||
stmt = insert(place_area_refs).values(
|
||||
place_id=row['place_id'], local_content_id=content_id,
|
||||
distance_m=distances[str(row['place_id'])], deleted=False,
|
||||
).on_conflict_do_update(
|
||||
index_elements=[place_area_refs.place_id, place_area_refs.local_content_id],
|
||||
set_={'distance_m': distances[str(row['place_id'])], 'deleted': False, 'updated_at': now},
|
||||
)
|
||||
await conn.execute(stmt)
|
||||
|
||||
print(json.dumps({
|
||||
'applied': apply, 'database': cfg.name, 'region': REGION,
|
||||
'contentId': str(content_id), 'naverPlaceId': NAVER_ID,
|
||||
'coordinates': {'latitude': lat, 'longitude': lng},
|
||||
'existingPlaces': [
|
||||
{**{k: v for k, v in r.items() if k not in ('latitude', 'longitude')},
|
||||
'distanceMeters': distances[str(r['place_id'])]}
|
||||
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))
|
||||
# ★ 업장마다 다른 거리라 사이트 개인화 맵(site_sections)에도 얹어야 캔버스·발행본이 읽는다
|
||||
# (services/local_content_service.py::_write_site_places 규약과 동일).
|
||||
service = LocalContentService()
|
||||
for row in targets:
|
||||
place_id = row['place_id']
|
||||
places_map = dict(await _site_places(place_id))
|
||||
prev = places_map.get(str(content_id)) or {}
|
||||
places_map[str(content_id)] = {
|
||||
'kind': 'restaurant',
|
||||
'distanceMeters': distances[str(place_id)],
|
||||
'hidden': bool(prev.get('hidden', False)),
|
||||
}
|
||||
await service._write_site_places(place_id, places_map)
|
||||
|
||||
# 업장마다 거리를 포함해 정확히 한 번 실렸는지 확인한다.
|
||||
mismatches = []
|
||||
for row in targets:
|
||||
place = SimpleNamespace(
|
||||
place_id=row['place_id'], region_code=REGION,
|
||||
latitude=row['latitude'], longitude=row['longitude'],
|
||||
)
|
||||
snapshot = await _local_contents(place)
|
||||
local, _ = _local(snapshot, _as_float(row['latitude']), _as_float(row['longitude']))
|
||||
matches = [r for r in local['restaurants'] if r['name'] == '한일옥']
|
||||
expected = distances[str(row['place_id'])]
|
||||
ok = len(matches) == 1 and (expected is None or matches[0].get('distanceMeters') == expected)
|
||||
if not ok:
|
||||
mismatches.append({'place': row['name'], 'matches': matches, 'expectedDistanceMeters': expected})
|
||||
print(json.dumps({'verified': not mismatches, 'mismatches': mismatches}, ensure_ascii=False))
|
||||
finally:
|
||||
await engine.dispose()
|
||||
await DB_SESSION_MNG.dispose_all()
|
||||
|
||||
@ -242,6 +242,11 @@ async def summarize_text(
|
||||
|
||||
if not summary:
|
||||
return None
|
||||
usage = result.usage
|
||||
LOG.i(
|
||||
f"[llm-text] 요약 {len(stripped)}자 → {len(summary)}자 · model={model} · "
|
||||
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${llm.price(model, usage)}"
|
||||
)
|
||||
if len(_SUMMARY_CACHE) >= _SUMMARY_CACHE_MAX:
|
||||
_SUMMARY_CACHE.clear() # 간단한 캐시 상한 — 관리 도구 트래픽 규모에는 LRU 가 과하다.
|
||||
_SUMMARY_CACHE[cache_key] = summary
|
||||
|
||||
39
solution/backend/services/external/tour_api.py
vendored
39
solution/backend/services/external/tour_api.py
vendored
@ -21,8 +21,10 @@ collector/tour_api_adapter.py 가 '사업장 1곳'의 fact 를 캐는 쪽이라
|
||||
진행 예정 축제(군산시간여행축제 등)는 끝내 안 잡혔다. searchFestival2 는 법정동(시도)
|
||||
단위로 묻지만 정확하고 기간까지 함께 준다 — 그래서 시도 전체를 받아 우리가 거리로 거른다.
|
||||
eventStartDate 는 파라미터로 준 날짜 **이후 시작하는** 행사만 거른다(이전에 시작해 아직
|
||||
진행 중인 행사는 잡히지 않는다 — 실측). 그래서 항상 **그 해 1월 1일**로 고정해 부르고,
|
||||
이미 끝난 행사(eventenddate < 오늘)만 우리가 한 번 더 거른다.
|
||||
진행 중인 행사는 잡히지 않는다 — 실측). 그래서 항상 **그 해 1월 1일**로 고정해 부른다.
|
||||
종료된 행사도 그대로 싣는다(2026-09-17 결정) — 시작일을 2020년으로 당겨 실측해도 API 자체가
|
||||
옛 행사를 추가로 주지 않아(최근~예정 위주) 여기서 더 거를 실익이 없고, 이미 끝난 축제를
|
||||
보여줄지는 노출 단계(local_content_service)의 몫으로 넘긴다.
|
||||
|
||||
★ 이미지 저작권 — 수집 단계에서 끝낸다 (collector/tour_api_adapter.py 와 같은 규칙)
|
||||
firstimage 는 공공누리 Type1(출처표시)·Type3(출처표시+변경금지)만 남긴다.
|
||||
@ -235,7 +237,7 @@ def _normalize_festival(item: dict, distance_m: int) -> Optional[dict]:
|
||||
|
||||
★ `_normalize` 와 같은 규약이다 — 렌더러 이름으로 바꿔 내보내고, 저장 자리는 부르는 쪽이 정한다.
|
||||
★ 기간(eventstartdate/enddate)은 **원값 그대로** 남긴다. 화면 문자열("2026.10.01 ~ …")로 미리
|
||||
구워 두면 노출 기간 필터(`_festival_not_ended`·display_end_at)가 읽을 값이 없어진다.
|
||||
구워 두면 정렬·계절 산출(site_payload._festival)이 읽을 값이 없어진다.
|
||||
날짜는 사실이고 문장은 표기다 — 사실만 저장한다.
|
||||
"""
|
||||
content_id = str(item.get("contentid") or "").strip()
|
||||
@ -266,26 +268,19 @@ def _normalize_festival(item: dict, distance_m: int) -> Optional[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def _festival_not_ended(body: dict, today: date) -> bool:
|
||||
"""종료일이 지났으면 끝난 축제 — 신지 않는다. 기간을 아예 모르면 못 믿으니 역시 뺀다.
|
||||
종료일 없이 시작일만 있으면(무기한 진행) 유지한다 — 끝났다는 증거가 없다."""
|
||||
end, start = body.get("eventenddate"), body.get("eventstartdate")
|
||||
ymd = today.strftime("%Y%m%d")
|
||||
if end:
|
||||
return len(end) == 8 and end.isdigit() and end >= ymd
|
||||
return bool(start)
|
||||
|
||||
|
||||
async def fetch_festivals_in_sido(client: httpx.AsyncClient, latitude: float, longitude: float,
|
||||
*, sido_code: str, today: date) -> list[dict]:
|
||||
"""업장이 속한 시도의 축제 **전부**(정규화, 거리순, 이미 끝난 것 제외). 반경으로 자르지 않는다.
|
||||
"""업장이 속한 시도의 축제 **전부**(정규화, 거리순, 종료 여부와 무관하게 전부). 반경으로 자르지 않는다.
|
||||
|
||||
★ 반경을 안 두는 이유(2026-09-08 결정): 축제는 차로 가는 행사라 20km 로 자르면 시도 안의
|
||||
큰 축제가 빠진다. 시도 전체를 그대로 싣고, 거리는 정렬·표시용으로만 잰다.
|
||||
(종류별 노출 상한은 스냅샷이 20건으로 자른다 — 사진 있는 것 우선 → 가까운 순.)
|
||||
★ locationBasedList2 의 위치 색인은 못 믿어서 searchFestival2 를 쓴다(위 모듈 docstring).
|
||||
eventStartDate 는 그 해 1월 1일로 **고정** — "오늘" 을 넣으면 그 이전에 시작해 아직 진행 중인
|
||||
축제가 파라미터 자체에서 빠진다(실측). 연초부터 전부 받고, 끝난 것만 여기서 거른다.
|
||||
축제가 파라미터 자체에서 빠진다(실측). 연초부터 전부 받는다.
|
||||
★ 종료된 축제도 거르지 않고 그대로 싣는다(2026-09-17 결정) — 시작일을 2020년으로 당겨 실측해도
|
||||
API 가 옛 행사를 추가로 주지 않아 더 거를 실익이 없었고, 실제로 보여줄지는 노출 단계
|
||||
(local_content_service.py — area_contents.display_end_at)가 정한다.
|
||||
★ 좌표 없는 항목은 뺀다 — distance_m 이 NOT NULL 이고, 거리 없는 카드는 도보 필터에 못 얹는다.
|
||||
"""
|
||||
start_date = date(today.year, 1, 1).strftime("%Y%m%d")
|
||||
@ -305,8 +300,6 @@ async def fetch_festivals_in_sido(client: httpx.AsyncClient, latitude: float, lo
|
||||
body = _normalize_festival(item, round(distance))
|
||||
if not body or body["contentid"] in seen:
|
||||
continue
|
||||
if not _festival_not_ended(body, today):
|
||||
continue
|
||||
seen.add(body["contentid"])
|
||||
out.append(body)
|
||||
if not items or page * PAGE_SIZE >= total:
|
||||
@ -334,15 +327,3 @@ async def fetch_content_class(client: httpx.AsyncClient, content_id: str) -> Opt
|
||||
return None
|
||||
code = str(items[0].get("lclsSystm2") or "").strip()
|
||||
return code or None
|
||||
|
||||
|
||||
def festival_is_current(period: Optional[tuple[str, str]], today: date) -> bool:
|
||||
"""종료일이 지났으면 끝난 축제 — 싣지 않는다. 기간을 아예 모르면(None) 못 믿으니 역시 뺀다.
|
||||
종료일 없이 시작일만 있으면(무기한 진행) 시작일이 지났어도 유지한다 — 끝났다는 증거가 없다."""
|
||||
if not period:
|
||||
return False
|
||||
start, end = period
|
||||
ymd = today.strftime("%Y%m%d")
|
||||
if end:
|
||||
return len(end) == 8 and end.isdigit() and end >= ymd
|
||||
return bool(start)
|
||||
|
||||
@ -49,21 +49,6 @@ RESTAURANT_RADIUS_M = 5_000
|
||||
ATTRACTION_RADIUS_M = 10_000
|
||||
|
||||
|
||||
def _festival_display_end(body: dict) -> datetime | None:
|
||||
"""eventenddate(YYYYMMDD) → 그 날 KST 자정(다음날 00:00) UTC.
|
||||
|
||||
★ 이 값이 있어야 끝난 축제가 발행본에서 저절로 빠진다 —
|
||||
스냅샷의 노출창 필터(snapshot._local_contents)가 display_end_at 을 본다."""
|
||||
raw = str(body.get("eventenddate") or "").strip()
|
||||
if len(raw) != 8 or not raw.isdigit():
|
||||
return None
|
||||
try:
|
||||
end_day = datetime(int(raw[:4]), int(raw[4:6]), int(raw[6:]), tzinfo=_KST)
|
||||
except ValueError:
|
||||
return None
|
||||
return (end_day + timedelta(days=1)).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _as_float(value) -> float | None:
|
||||
try:
|
||||
return float(value) if value is not None else None
|
||||
@ -195,9 +180,8 @@ class LocalContentService:
|
||||
# ★ has_image 컬럼은 두지 않는다 — body.firstimage 가 이미 그 사실이다.
|
||||
# 같은 값을 두 곳에 두면 한쪽만 갱신되는 날이 온다.
|
||||
"status": LocalContentStatus.PUBLISHED.value,
|
||||
"display_end_at": (
|
||||
_festival_display_end(body) if body["content_type"] == LocalContentType.FESTIVAL.value else None
|
||||
),
|
||||
# ★ 축제도 종료일과 무관하게 노출한다(2026-09-17 결정) — display_end_at 을 두지 않는다.
|
||||
"display_end_at": None,
|
||||
"collected_at": now,
|
||||
}
|
||||
write_err = await DB_SESSION_MNG.execute_lambda_run(
|
||||
|
||||
@ -1,55 +1,55 @@
|
||||
{
|
||||
"_generated": "npm run export:prompts — 손으로 고치지 않는다",
|
||||
"rules": "\n[공통 규칙]\n1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다.\n2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 지어내지 않는다.\n3. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다. 검색 결과 주소는 쓰지 않는다.\n4. 근거가 확실하면 verified 를 \"확인\", 애매하면 \"확인필요\" 로 적는다. 애매한 걸 \"확인\" 으로 올리지 않는다.\n5. 가사·시·소설의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다.\n6. 설명 문장은 항목당 두 문장을 넘기지 않는다.\n7. 이미지 주소는 만들지 않는다. 필요하면 imageQuery 에 검색어만 적는다.\n",
|
||||
"rules": "\r\n[공통 규칙]\r\n1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다.\r\n2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 지어내지 않는다.\r\n3. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다. 검색 결과 주소는 쓰지 않는다.\r\n4. 근거가 확실하면 verified 를 \"확인\", 애매하면 \"확인필요\" 로 적는다. 애매한 걸 \"확인\" 으로 올리지 않는다.\r\n5. 가사·시·소설의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다.\r\n6. 설명 문장은 항목당 두 문장을 넘기지 않는다.\r\n7. 이미지 주소는 만들지 않는다. 필요하면 imageQuery 에 검색어만 적는다.\r\n",
|
||||
"specs": {
|
||||
"songs": {
|
||||
"kind": "songs",
|
||||
"label": "가요 다방",
|
||||
"maxItems": 8,
|
||||
"task": "[해야 할 일]\n[지역]을 노래한 대중가요를 8곡까지 찾아 아래 JSON 으로 정리한다.\n1960~80년대 곡을 우선하고, 지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다.\n\n[스키마]\n{ \"kind\":\"songs\", \"version\":1, \"title\":\"가요 다방\", \"subtitle\":\"...\", \"items\":[\n { \"title\":\"곡명\", \"artist\":\"가수\", \"lyricist\":\"작사\", \"composer\":\"작곡\",\n \"year\":1966, \"label\":\"음반사\", \"labelColor\":\"#d4551f\",\n \"story\":\"곡의 배경 (두 문장 이내, 가사 없이)\",\n \"connection\":\"[업소]와 이 곡을 잇는 한 문장\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
|
||||
"rules": "\n[이 아이템만의 규칙]\n· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.\n· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.\n **이건 사실이 아니라 디자인 값이다 — 공통 규칙 2 를 적용하지 말고 곡마다 반드시 채운다.**\n 앞뒤 곡과 같은 색을 쓰지 않는다. 비워 두면 판이 전부 한 색으로 깔린다(실측 2026-09-15:\n 67곡 중 63곡이 비어 있었다).\n· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. \"미상\" 이라고 쓰지 않는다.\n"
|
||||
"task": "[해야 할 일]\r\n[지역]을 노래한 대중가요를 8곡까지 찾아 아래 JSON 으로 정리한다.\r\n1960~80년대 곡을 우선하고, 지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다.\r\n\r\n[스키마]\r\n{ \"kind\":\"songs\", \"version\":1, \"title\":\"가요 다방\", \"subtitle\":\"...\", \"items\":[\r\n { \"title\":\"곡명\", \"artist\":\"가수\", \"lyricist\":\"작사\", \"composer\":\"작곡\",\r\n \"year\":1966, \"label\":\"음반사\", \"labelColor\":\"#d4551f\",\r\n \"story\":\"곡의 배경 (두 문장 이내, 가사 없이)\",\r\n \"connection\":\"[업소]와 이 곡을 잇는 한 문장\",\r\n \"verified\":\"확인|확인필요\",\r\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
|
||||
"rules": "\r\n[이 아이템만의 규칙]\r\n· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.\r\n· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.\r\n **이건 사실이 아니라 디자인 값이다 — 공통 규칙 2 를 적용하지 말고 곡마다 반드시 채운다.**\r\n 앞뒤 곡과 같은 색을 쓰지 않는다. 비워 두면 판이 전부 한 색으로 깔린다(실측 2026-09-15:\r\n 67곡 중 63곡이 비어 있었다).\r\n· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. \"미상\" 이라고 쓰지 않는다.\r\n"
|
||||
},
|
||||
"daily": {
|
||||
"kind": "daily",
|
||||
"label": "오늘의 한 장",
|
||||
"maxItems": 30,
|
||||
"task": "[해야 할 일]\n[지역]의 이야깃거리를 30개 찾아, 하루에 하나씩 뜯어 보는 일력용 JSON 으로 만든다.\n같은 주제를 두 번 쓰지 않는다. 계절이 맞는 날짜에 배치한다(축제는 실제 개최 시기에).\n\n[스키마]\n{ \"kind\":\"daily\", \"version\":1, \"title\":\"오늘의 한 장\", \"items\":[\n { \"monthDay\":\"09-02\", \"category\":\"역사|인물|장소|음식|축제|바다|문학\",\n \"title\":\"제목\", \"body\":\"두 문장 이내\", \"season\":\"봄|여름|가을|겨울\",\n \"tags\":[\"#태그\"], \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
|
||||
"rules": "\n[이 아이템만의 규칙]\n· monthDay 는 MM-DD 다. 연도를 넣지 않는다 — 해마다 다시 쓰는 일력이다.\n· 30개를 억지로 채우지 마라. 확실한 것이 12개면 12개만 낸다.\n· 같은 장소를 계절만 바꿔 반복하지 않는다.\n"
|
||||
"task": "[해야 할 일]\r\n[지역]의 이야깃거리를 30개 찾아, 하루에 하나씩 뜯어 보는 일력용 JSON 으로 만든다.\r\n같은 주제를 두 번 쓰지 않는다. 계절이 맞는 날짜에 배치한다(축제는 실제 개최 시기에).\r\n\r\n[스키마]\r\n{ \"kind\":\"daily\", \"version\":1, \"title\":\"오늘의 한 장\", \"items\":[\r\n { \"monthDay\":\"09-02\", \"category\":\"역사|인물|장소|음식|축제|바다|문학\",\r\n \"title\":\"제목\", \"body\":\"두 문장 이내\", \"season\":\"봄|여름|가을|겨울\",\r\n \"tags\":[\"#태그\"], \"verified\":\"확인|확인필요\",\r\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
|
||||
"rules": "\r\n[이 아이템만의 규칙]\r\n· monthDay 는 MM-DD 다. 연도를 넣지 않는다 — 해마다 다시 쓰는 일력이다.\r\n· 30개를 억지로 채우지 마라. 확실한 것이 12개면 12개만 낸다.\r\n· 같은 장소를 계절만 바꿔 반복하지 않는다.\r\n"
|
||||
},
|
||||
"people": {
|
||||
"kind": "people",
|
||||
"label": "인물 열전",
|
||||
"maxItems": 10,
|
||||
"task": "[해야 할 일]\n[지역] 출신이거나 [지역]과 깊이 얽힌 인물을 10명까지 찾는다.\n문학·음악·미술·역사 인물을 고루 섞고, 생존 인물은 공개된 사실만 쓴다.\n\n[스키마]\n{ \"kind\":\"people\", \"version\":1, \"title\":\"인물 열전\", \"items\":[\n { \"name\":\"이름\", \"aka\":\"호·예명\", \"years\":\"1902–1950\", \"role\":\"소설가\",\n \"oneLine\":\"한 문장 소개\", \"imageQuery\":\"사진 검색어\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
|
||||
"rules": "\n[이 아이템만의 규칙]\n· \"~ 출신으로 알려진\" 처럼 근거가 전언뿐이면 verified 를 \"확인필요\" 로 한다.\n· 생존 인물의 가족·거주지·건강 같은 사생활은 쓰지 않는다.\n· 사진 URL 을 넣지 않는다. imageQuery 만 넣는다 — 초상권과 저작권은 사장님이 확인한다.\n"
|
||||
"task": "[해야 할 일]\r\n[지역] 출신이거나 [지역]과 깊이 얽힌 인물을 10명까지 찾는다.\r\n문학·음악·미술·역사 인물을 고루 섞고, 생존 인물은 공개된 사실만 쓴다.\r\n\r\n[스키마]\r\n{ \"kind\":\"people\", \"version\":1, \"title\":\"인물 열전\", \"items\":[\r\n { \"name\":\"이름\", \"aka\":\"호·예명\", \"years\":\"1902–1950\", \"role\":\"소설가\",\r\n \"oneLine\":\"한 문장 소개\", \"imageQuery\":\"사진 검색어\",\r\n \"verified\":\"확인|확인필요\",\r\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
|
||||
"rules": "\r\n[이 아이템만의 규칙]\r\n· \"~ 출신으로 알려진\" 처럼 근거가 전언뿐이면 verified 를 \"확인필요\" 로 한다.\r\n· 생존 인물의 가족·거주지·건강 같은 사생활은 쓰지 않는다.\r\n· 사진 URL 을 넣지 않는다. imageQuery 만 넣는다 — 초상권과 저작권은 사장님이 확인한다.\r\n"
|
||||
},
|
||||
"chronicle": {
|
||||
"kind": "chronicle",
|
||||
"label": "시간의 골목",
|
||||
"maxItems": 14,
|
||||
"task": "[해야 할 일]\n[지역]의 역사를 연도순으로 10~14개 사건으로 정리한다.\n가장 오래된 것부터 가장 최근까지 고르게 펴고, 한 시대에 몰지 않는다.\n\n[스키마]\n{ \"kind\":\"chronicle\", \"version\":1, \"title\":\"시간의 골목\", \"items\":[\n { \"year\":1899, \"title\":\"사건 이름\", \"summary\":\"두 문장 이내\",\n \"place\":\"지금 가 볼 수 있는 자리\", \"turning\":true,\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
|
||||
"rules": "\n[이 아이템만의 규칙]\n· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.\n· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.\n· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.\n· 다만 **찾아갈 수 있는 자리가 있으면 반드시 적는다.** 행정 개편처럼 장소가 없어 보이는\n 사건에도 그 일이 남긴 자리가 대개 있다(개항 → 항구, 부두 건설 → 그 부두, 준공 → 그 구조물).\n 이 값으로 공공데이터에서 그 해의 사진을 찾아 붙인다 — 비면 연표가 활자만으로 선다.\n"
|
||||
"task": "[해야 할 일]\r\n[지역]의 역사를 연도순으로 10~14개 사건으로 정리한다.\r\n가장 오래된 것부터 가장 최근까지 고르게 펴고, 한 시대에 몰지 않는다.\r\n\r\n[스키마]\r\n{ \"kind\":\"chronicle\", \"version\":1, \"title\":\"시간의 골목\", \"items\":[\r\n { \"year\":1899, \"title\":\"사건 이름\", \"summary\":\"두 문장 이내\",\r\n \"place\":\"지금 가 볼 수 있는 자리\", \"turning\":true,\r\n \"verified\":\"확인|확인필요\",\r\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
|
||||
"rules": "\r\n[이 아이템만의 규칙]\r\n· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.\r\n· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.\r\n· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.\r\n· 다만 **찾아갈 수 있는 자리가 있으면 반드시 적는다.** 행정 개편처럼 장소가 없어 보이는\r\n 사건에도 그 일이 남긴 자리가 대개 있다(개항 → 항구, 부두 건설 → 그 부두, 준공 → 그 구조물).\r\n 이 값으로 공공데이터에서 그 해의 사진을 찾아 붙인다 — 비면 연표가 활자만으로 선다.\r\n"
|
||||
},
|
||||
"reading": {
|
||||
"kind": "reading",
|
||||
"label": "지역 읽기",
|
||||
"maxItems": 34,
|
||||
"task": "[해야 할 일]\n[지역]을 소개하는 글 30~34꼭지를 갈래로 나눠 쓴다.\n갈래는 다섯이다 — 문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활.\n한 갈래에 몰지 않되, 댈 수 있는 사실이 적은 갈래는 적게 쓴다.\n\n[스키마]\n{ \"kind\":\"reading\", \"version\":1, \"title\":\"[지역] 읽기\",\n \"subtitle\":\"문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활 — 올 때마다 다른 몇 가지\",\n \"items\":[\n { \"group\":\"문학|섬과 바다|역사|장소|음식과 생활\",\n \"title\":\"꼭지 제목\", \"body\":\"서너 문장\", \"year\":1937 } ] }",
|
||||
"rules": "\n[이 아이템만의 규칙]\n· body 는 **서너 문장**이다. 두 문장이면 카드가 한 줄로 접혀 빈 카드처럼 보인다.\n 다만 문장을 늘리려고 없는 숫자·연도·거리를 지어내지 않는다 — 널리 알려진 배경만 보탠다.\n· **이미 다른 자리에 선 것은 쓰지 않는다.** 인물·대중가요·축제·연표 사건·주변 명소·맛집은\n 각자 섹션이 있다. 같은 것을 두 번 세우면 페이지만 길어지고 손님은 같은 말을 두 번 읽는다.\n· year 는 연도를 댈 수 있는 꼭지에만 쓴다. 짐작해서 넣지 않는다.\n· source 와 verified 를 적지 않는다. **주소를 짐작해 적으면 없는 문서로 이어진다** —\n 제목으로 찾아가는 검색 링크를 서버가 붙인다(2026-09-14 대표 지시).\n· 시·소설·가사의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다.\n· 부제에 전체 개수를 적지 않는다. 화면은 이 중 5~6개만 매번 무작위로 보여준다 —\n 숫자를 박으면 보이는 개수와 어긋난다.\n"
|
||||
"maxItems": 16,
|
||||
"task": "[해야 할 일]\r\n[지역] 출신이거나 [지역]과 깊이 얽힌 **인물**, [지역]을 무대로 하거나 다룬 **작품**의 이야기를\r\n갈래로 나눠 12~16꼭지 쓴다. 장소·역사·먹거리 소개는 다루지 않는다(각자 섹션이 있다).\r\n한 갈래에 몰지 않되, 댈 수 있는 사실이 적은 갈래는 적게 쓴다.\r\n\r\n[스키마]\r\n{ \"kind\":\"reading\", \"version\":1, \"title\":\"[지역] 읽기\",\r\n \"subtitle\":\"인물 · 문학 — 올 때마다 다른 몇 가지\",\r\n \"items\":[\r\n { \"group\":\"인물|문학\",\r\n \"title\":\"꼭지 제목\", \"body\":\"서너 문장\", \"year\":1937 } ] }",
|
||||
"rules": "\r\n[이 아이템만의 규칙]\r\n· body 는 **서너 문장**이다. 두 문장이면 카드가 한 줄로 접혀 빈 카드처럼 보인다.\r\n 다만 문장을 늘리려고 없는 숫자·연도·거리를 지어내지 않는다 — 널리 알려진 배경만 보탠다.\r\n· \"인물\" 은 그 인물의 개괄 소개(생몰년·직함 한 줄)가 아니라, **그 사람이 남긴 작품·활동·일화**\r\n 중심으로 쓴다. '인물 열전' 섹션과 같은 사람을 다뤄도 되지만, 거기 있는 소개 문장을 반복하지\r\n 않는다 — 인물 열전에 없는 사람이어도 좋다.\r\n· \"문학\" 은 그 지역을 무대로 하거나 다룬 작품(소설·시·수필 등)의 배경과 이야기를 쓴다.\r\n· **장소·역사·먹거리 소개, 그리고 대중가요·축제·연표 사건·주변 명소·맛집처럼 이미 다른 자리에\r\n 선 것은 쓰지 않는다.** 같은 것을 두 번 세우면 페이지만 길어지고 손님은 같은 말을 두 번 읽는다.\r\n· year 는 연도를 댈 수 있는 꼭지에만 쓴다. 짐작해서 넣지 않는다.\r\n· source 와 verified 를 적지 않는다. **주소를 짐작해 적으면 없는 문서로 이어진다** —\r\n 제목으로 찾아가는 검색 링크를 서버가 붙인다(2026-09-14 대표 지시).\r\n· 시·소설·가사의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다.\r\n· 부제에 전체 개수를 적지 않는다. 화면은 이 중 5~6개만 매번 무작위로 보여준다 —\r\n 숫자를 박으면 보이는 개수와 어긋난다.\r\n"
|
||||
},
|
||||
"postcard": {
|
||||
"kind": "postcard",
|
||||
"label": "오늘의 엽서",
|
||||
"maxItems": 12,
|
||||
"task": "[해야 할 일]\n[지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다.\n사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다.\n\n[스키마]\n{ \"kind\":\"postcard\", \"version\":1, \"title\":\"오늘의 엽서\", \"items\":[\n { \"line\":\"한 문장\", \"hashtags\":[\"#태그\"], \"place\":\"장소\",\n \"postmark\":\"소인에 찍을 짧은 지명\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
|
||||
"rules": "\n[이 아이템만의 규칙]\n· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.\n· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.\n· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.\n· **같은 대상을 두 번 쓰지 않는다.** 열두 장이 전부 다른 곳·다른 이야기여야 한다 —\n 한 시설의 주소·휴관일·운영시간을 나눠 적으면 엽서가 아니라 안내문 열두 장이 된다\n (실측 2026-09-10: 네 장이 같은 박물관의 관람안내였다).\n· 운영시간·휴관일·주소·요금은 엽서에 적지 않는다. 그건 이용 정보지 엽서 문장이 아니다.\n· place 는 사진을 찾는 열쇠다. **찾아갈 수 있는 곳 이름을 정확히** 적는다 —\n 이 값으로 공공데이터에서 앞면 사진을 찾고, 못 찾으면 그 엽서는 실리지 않는다.\n"
|
||||
"task": "[해야 할 일]\r\n[지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다.\r\n사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다.\r\n\r\n[스키마]\r\n{ \"kind\":\"postcard\", \"version\":1, \"title\":\"오늘의 엽서\", \"items\":[\r\n { \"line\":\"한 문장\", \"hashtags\":[\"#태그\"], \"place\":\"장소\",\r\n \"postmark\":\"소인에 찍을 짧은 지명\",\r\n \"verified\":\"확인|확인필요\",\r\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
|
||||
"rules": "\r\n[이 아이템만의 규칙]\r\n· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.\r\n· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.\r\n· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.\r\n· **같은 대상을 두 번 쓰지 않는다.** 열두 장이 전부 다른 곳·다른 이야기여야 한다 —\r\n 한 시설의 주소·휴관일·운영시간을 나눠 적으면 엽서가 아니라 안내문 열두 장이 된다\r\n (실측 2026-09-10: 네 장이 같은 박물관의 관람안내였다).\r\n· 운영시간·휴관일·주소·요금은 엽서에 적지 않는다. 그건 이용 정보지 엽서 문장이 아니다.\r\n· place 는 사진을 찾는 열쇠다. **찾아갈 수 있는 곳 이름을 정확히** 적는다 —\r\n 이 값으로 공공데이터에서 앞면 사진을 찾고, 못 찾으면 그 엽서는 실리지 않는다.\r\n"
|
||||
},
|
||||
"quiz": {
|
||||
"kind": "quiz",
|
||||
"label": "뒤집어 보는 질문",
|
||||
"maxItems": 12,
|
||||
"task": "[해야 할 일]\n[지역]을 소재로, 아이와 어른이 함께 생각해 볼 질문을 12개 만든다.\n질문은 검색하면 바로 나오는 단답형이 아니라 \"왜\" 와 \"어떻게\" 를 묻는 것으로 한다.\n\n[스키마]\n{ \"kind\":\"quiz\", \"version\":1, \"title\":\"뒤집어 보는 질문\", \"items\":[\n { \"question\":\"질문 한 문장\", \"hint\":\"두 문장 이내 힌트\",\n \"topic\":\"관련 장소·주제\", \"level\":\"초등|중등|어른\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
|
||||
"rules": "\n[이 아이템만의 규칙]\n· answer 필드는 스키마에 없다. 정답을 단정하지 않는다 — 힌트까지만 준다.\n· 힌트에 사실을 넣되, 확실하지 않으면 그 항목을 통째로 뺀다.\n· 질문에 지역 이름을 넣어 어디 이야기인지 알 수 있게 한다.\n"
|
||||
"task": "[해야 할 일]\r\n[지역]을 소재로, 아이와 어른이 함께 생각해 볼 질문을 12개 만든다.\r\n질문은 검색하면 바로 나오는 단답형이 아니라 \"왜\" 와 \"어떻게\" 를 묻는 것으로 한다.\r\n\r\n[스키마]\r\n{ \"kind\":\"quiz\", \"version\":1, \"title\":\"뒤집어 보는 질문\", \"items\":[\r\n { \"question\":\"질문 한 문장\", \"hint\":\"두 문장 이내 힌트\",\r\n \"topic\":\"관련 장소·주제\", \"level\":\"초등|중등|어른\",\r\n \"verified\":\"확인|확인필요\",\r\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
|
||||
"rules": "\r\n[이 아이템만의 규칙]\r\n· answer 필드는 스키마에 없다. 정답을 단정하지 않는다 — 힌트까지만 준다.\r\n· 힌트에 사실을 넣되, 확실하지 않으면 그 항목을 통째로 뺀다.\r\n· 질문에 지역 이름을 넣어 어디 이야기인지 알 수 있게 한다.\r\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -509,10 +509,22 @@ def _weather_condition(code) -> str:
|
||||
return "맑음"
|
||||
if 1 <= value <= 3:
|
||||
return "구름많음"
|
||||
if 50 <= value <= 69:
|
||||
if value in (45, 48):
|
||||
return "안개"
|
||||
if value in (51, 53, 55):
|
||||
return "이슬비"
|
||||
if value in (56, 57, 66, 67):
|
||||
return "어는비"
|
||||
if value in (61, 63):
|
||||
return "비"
|
||||
if value == 65:
|
||||
return "강한비"
|
||||
if 70 <= value <= 79:
|
||||
return "눈"
|
||||
if value in (80, 81, 82, 85, 86):
|
||||
return "소나기"
|
||||
if 95 <= value <= 99:
|
||||
return "뇌우"
|
||||
return "흐림"
|
||||
|
||||
|
||||
|
||||
@ -202,15 +202,17 @@ async def _local_contents(place) -> dict:
|
||||
|
||||
두 캐시를 합친다 —
|
||||
area_contents (region_code) 날씨 + 운영자가 수기로 발행한 항목
|
||||
place_area_refs (place_id) TourAPI 반경 수집분(맛집·관광지·축제·여행코스). 숨김·종료된 것 제외
|
||||
place_area_refs (place_id) TourAPI 반경 수집분(맛집·관광지·축제·여행코스). 숨김만 제외
|
||||
(축제는 종료 여부와 무관하게 노출, 2026-09-17 결정)
|
||||
|
||||
★ 노출 가능 = PUBLISHED + 노출 기간 안.
|
||||
area_contents.status 는 운영 관리자의 검수 결과다(REVIEW=1 · PUBLISHED=2 · ENDED=3).
|
||||
REVIEW 는 아직 사람이 확인하지 않은 외부 API 원문이고, ENDED 는 내린 것이다.
|
||||
둘 중 하나라도 사이트로 새면 '미검증 값 노출 금지'가 깨진다 — fact 를 VERIFIED/CORRECTED 로,
|
||||
사진을 APPROVED 로 거르는 것과 같은 규칙을 같은 이유로 적용한다.
|
||||
display_start_at/display_end_at 은 운영자가 정한 노출 창이다. 기간이 지난 축제를
|
||||
"이번 주말 행사"로 걸어두는 것도 틀린 정보라 여기서 함께 막는다.
|
||||
display_start_at/display_end_at 은 운영자가 수기로 정한 노출 창이다(운영자가 걸어 둔 항목에만
|
||||
쓰인다 — TourAPI 로 긁은 축제는 종료 여부와 무관하게 둘 다 NULL, 2026-09-17 결정:
|
||||
services/external/tour_api.py, local_content_service.py 참고).
|
||||
|
||||
★ expires_at 은 보지 않는다. 모델 주석대로 그건 '갱신 대상'이라는 표시지 '못 쓰는 값'이 아니다
|
||||
(외부 API 가 죽어도 직전 값을 유지하는 게 이 캐시의 규약이다). 게다가 날씨는 렌더러가
|
||||
|
||||
@ -28,6 +28,9 @@ def nol_stay_guide(url: str, raw) -> dict:
|
||||
lines.pop(0)
|
||||
# 제목 중복과 UI 버튼만 제외한다. 요약·추론 없이 수집 문장을 보존한다.
|
||||
body = "\n".join(line for line in lines if line.strip() != "전체보기").strip()
|
||||
# NOL 은 원 플랫폼 브랜드다 — 사장님 사이트에 그대로 실으면 남의 고객센터·정책을
|
||||
# 우리 것처럼 안내하게 된다. 문장 자체는 그대로 두고 그 단어만 지운다.
|
||||
body = re.sub(r"NOL\s*", "", body)
|
||||
if body:
|
||||
guide[labels[label]] = body
|
||||
if guide:
|
||||
|
||||
@ -4,7 +4,13 @@
|
||||
"구름많음": ["구름이 많은 날에는 실내에서 다음 일정을 천천히 골라 보세요.", "여행 사진을 정리하며 잠깐 쉬어 가세요.", "함께 온 사람과 오늘 가장 좋았던 순간을 이야기해 보세요.", "바깥 일정 사이에 여유로운 휴식 시간을 넣어 보세요.", "오늘의 기분을 짧은 글로 남겨도 좋겠습니다."],
|
||||
"흐림": ["흐린 날에는 실내에서 느긋하게 쉬어 가도 좋겠습니다.", "잠깐 책을 읽으며 다음 일정을 기다려 보세요.", "바깥 풍경 대신 오늘 나눈 이야기에 집중해 보세요.", "천천히 쉬면서 돌아갈 때 가져갈 추억을 골라 보세요.", "분주한 일정 사이에 조용한 휴식을 끼워 넣어 보세요."],
|
||||
"비": ["비가 오는 동안에는 실내에서 쉬며 일정을 조정해 보세요.", "서두르지 말고 비가 잦아드는지 확인한 뒤 움직이세요.", "빗소리를 들으며 읽고 싶었던 글을 펼쳐 보세요.", "젖은 물건을 정리하고 잠깐 쉬어 가세요.", "바깥 일정 대신 함께 찍은 사진을 나눠 봐도 좋겠습니다."],
|
||||
"눈": ["눈 오는 날에는 실내에서 쉬며 바깥 상황을 살펴보세요.", "다음 이동을 서두르지 말고 쉬어 가세요.", "따뜻하게 머물며 오늘의 여행을 한 줄 남겨 보세요.", "실내에서 다음 일정과 이동편을 확인해 보세요.", "눈길을 나서기 전에는 여유 있게 준비하세요."]
|
||||
"눈": ["눈 오는 날에는 실내에서 쉬며 바깥 상황을 살펴보세요.", "다음 이동을 서두르지 말고 쉬어 가세요.", "따뜻하게 머물며 오늘의 여행을 한 줄 남겨 보세요.", "실내에서 다음 일정과 이동편을 확인해 보세요.", "눈길을 나서기 전에는 여유 있게 준비하세요."],
|
||||
"안개": ["안개가 낀 날에는 이동 전에 시야와 도로 상황을 확인해 보세요.", "짙은 안개 속에서는 서두르지 말고 여유 있게 움직이세요.", "안개가 걷히기를 기다리며 실내에서 쉬어 가도 좋겠습니다.", "다음 이동 경로의 시야 확보 여부를 미리 확인해 두세요.", "안개 속 야외 일정보다는 가까운 곳부터 살펴보세요."],
|
||||
"소나기": ["소나기가 지나가는 동안에는 잠시 실내에서 머물러 보세요.", "갑자기 쏟아지는 비에 대비해 우산이나 우비를 챙기세요.", "소나기가 그치면 나설 수 있도록 다음 일정에 여유를 두세요.", "젖은 길은 미끄러울 수 있으니 이동할 때 주의하세요.", "소나기 소식이 있다면 바깥 일정은 짧게 잡아 보세요."],
|
||||
"뇌우": ["천둥·번개가 치는 동안에는 야외 활동을 멈추고 실내에 머물러 있으세요.", "뇌우가 지나갈 때까지 개방된 장소나 높은 곳은 피하세요.", "기상특보를 확인하고 이동 일정을 조정해 보세요.", "번개가 잦아들 때까지 실내에서 안전하게 기다려 보세요.", "뇌우가 지나간 뒤에는 도로 상황을 확인한 뒤 움직이세요."],
|
||||
"이슬비": ["가벼운 이슬비라면 짧은 외출 정도는 괜찮습니다.", "이슬비가 흩날릴 때는 가벼운 겉옷 하나면 충분합니다.", "우산 없이도 짧은 이동이라면 크게 무리는 없겠습니다.", "이슬비가 그치는 틈을 봐서 다음 일정을 잡아 보세요.", "옷이 살짝 젖을 수 있으니 여벌 준비 정도만 해 두세요."],
|
||||
"강한비": ["빗줄기가 굵을 때는 이동을 미루고 실내에서 기다리세요.", "강한 비가 그칠 때까지 바깥 일정은 잠시 접어 두세요.", "물웅덩이·배수 상황을 살피고 이동 경로를 다시 확인하세요.", "우산만으로는 부족할 수 있으니 방수 옷차림을 챙기세요.", "굵은 빗속 이동은 삼가고 안전한 곳에서 쉬어 가세요."],
|
||||
"어는비": ["어는비가 내릴 때는 바깥 이동을 삼가고 실내에 머무르세요.", "도로가 얼어붙을 수 있으니 되도록 나서지 마세요.", "부득이 이동해야 한다면 걸음을 평소보다 훨씬 천천히 옮기세요.", "차량 이동도 미끄러질 수 있으니 서두르지 마세요.", "기상특보를 확인하고 해빙될 때까지 일정을 미뤄 두세요."]
|
||||
},
|
||||
"temperature": {
|
||||
"혹서": ["더위가 강한 시간에는 바깥 이동을 줄이고 실내 위주로 일정을 잡아 보세요.", "한낮 산책은 짧게 하고 충분히 쉬어 가세요.", "외출 전 기상특보와 방문할 곳의 운영 여부를 확인하세요.", "다음 장소로 이동할 때에는 쉴 시간을 넉넉히 두세요.", "긴 야외 일정은 미루고 가까운 동선부터 살펴보세요."],
|
||||
|
||||
@ -351,7 +351,7 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
|
||||
kind: 'reading',
|
||||
version: 1,
|
||||
title: '군산 읽기',
|
||||
subtitle: '문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활 — 올 때마다 다른 몇 가지',
|
||||
subtitle: '인물 · 문학 — 올 때마다 다른 몇 가지',
|
||||
items: [
|
||||
{
|
||||
group: '문학',
|
||||
@ -364,12 +364,12 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
|
||||
year: 1937,
|
||||
},
|
||||
{
|
||||
group: '섬과 바다',
|
||||
title: '고군산군도',
|
||||
group: '인물',
|
||||
title: '채만식의 풍자',
|
||||
body:
|
||||
'예순 남짓한 섬이 모인 군도입니다. 새만금 방조제가 육지와 신시도를 잇고, ' +
|
||||
'그 뒤로 무녀도 · 선유도 · 장자도가 다리로 차례로 이어져 차로 들어갈 수 ' +
|
||||
'있습니다. 섬마다 성격이 달라 하루로는 다 못 봅니다.',
|
||||
'웃기면서 찌르는 문장이 이 작가의 방식입니다. 『레디메이드 인생』 · 『태평천하』 같은 ' +
|
||||
'다른 대표작에서도 이어지는 태도로, 식민지 도시의 부조리를 인물의 우스꽝스러운 ' +
|
||||
'말과 행동으로 드러냅니다. 개항장 군산에서 본 것이 그 풍자의 바탕에 있습니다.',
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -382,7 +382,7 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
|
||||
(`backend/services/grounding/story.py` `_SEARCH_LINK_KINDS`). 손으로 적는 칸을 두면
|
||||
거기 적힌 주소가 그 규칙을 비껴간다. */
|
||||
fields: [
|
||||
{key: 'group', label: '갈래', half: true, hint: '문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활'},
|
||||
{key: 'group', label: '갈래', half: true, hint: '인물 · 문학'},
|
||||
{key: 'year', label: '연도', type: 'number', half: true},
|
||||
{key: 'title', label: '꼭지 제목'},
|
||||
{key: 'body', label: '본문', type: 'area', hint: '서너 문장'},
|
||||
|
||||
@ -171,24 +171,28 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = {
|
||||
reading: {
|
||||
kind: 'reading',
|
||||
label: '지역 읽기',
|
||||
maxItems: 34,
|
||||
maxItems: 16,
|
||||
task: `[해야 할 일]
|
||||
[지역]을 소개하는 글 30~34꼭지를 갈래로 나눠 쓴다.
|
||||
갈래는 다섯이다 — 문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활.
|
||||
[지역] 출신이거나 [지역]과 깊이 얽힌 **인물**, [지역]을 무대로 하거나 다룬 **작품**의 이야기를
|
||||
갈래로 나눠 12~16꼭지 쓴다. 장소·역사·먹거리 소개는 다루지 않는다(각자 섹션이 있다).
|
||||
한 갈래에 몰지 않되, 댈 수 있는 사실이 적은 갈래는 적게 쓴다.
|
||||
|
||||
[스키마]
|
||||
{ "kind":"reading", "version":1, "title":"[지역] 읽기",
|
||||
"subtitle":"문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활 — 올 때마다 다른 몇 가지",
|
||||
"subtitle":"인물 · 문학 — 올 때마다 다른 몇 가지",
|
||||
"items":[
|
||||
{ "group":"문학|섬과 바다|역사|장소|음식과 생활",
|
||||
{ "group":"인물|문학",
|
||||
"title":"꼭지 제목", "body":"서너 문장", "year":1937 } ] }`,
|
||||
rules: `
|
||||
[이 아이템만의 규칙]
|
||||
· body 는 **서너 문장**이다. 두 문장이면 카드가 한 줄로 접혀 빈 카드처럼 보인다.
|
||||
다만 문장을 늘리려고 없는 숫자·연도·거리를 지어내지 않는다 — 널리 알려진 배경만 보탠다.
|
||||
· **이미 다른 자리에 선 것은 쓰지 않는다.** 인물·대중가요·축제·연표 사건·주변 명소·맛집은
|
||||
각자 섹션이 있다. 같은 것을 두 번 세우면 페이지만 길어지고 손님은 같은 말을 두 번 읽는다.
|
||||
· "인물" 은 그 인물의 개괄 소개(생몰년·직함 한 줄)가 아니라, **그 사람이 남긴 작품·활동·일화**
|
||||
중심으로 쓴다. '인물 열전' 섹션과 같은 사람을 다뤄도 되지만, 거기 있는 소개 문장을 반복하지
|
||||
않는다 — 인물 열전에 없는 사람이어도 좋다.
|
||||
· "문학" 은 그 지역을 무대로 하거나 다룬 작품(소설·시·수필 등)의 배경과 이야기를 쓴다.
|
||||
· **장소·역사·먹거리 소개, 그리고 대중가요·축제·연표 사건·주변 명소·맛집처럼 이미 다른 자리에
|
||||
선 것은 쓰지 않는다.** 같은 것을 두 번 세우면 페이지만 길어지고 손님은 같은 말을 두 번 읽는다.
|
||||
· year 는 연도를 댈 수 있는 꼭지에만 쓴다. 짐작해서 넣지 않는다.
|
||||
· source 와 verified 를 적지 않는다. **주소를 짐작해 적으면 없는 문서로 이어진다** —
|
||||
제목으로 찾아가는 검색 링크를 서버가 붙인다(2026-09-14 대표 지시).
|
||||
|
||||
@ -287,7 +287,9 @@ export interface WeatherSnapshot {
|
||||
* ★ 사장님이 자기 집을 두고 쓰는 문장이다 — 날씨 예보가 아니다.
|
||||
*/
|
||||
notes?: Partial<Record<WeatherMood, string>>;
|
||||
noteSets?: Partial<Record<WeatherMood | '구름많음', string[]>>;
|
||||
noteSets?: Partial<
|
||||
Record<WeatherMood | '구름많음' | '안개' | '소나기' | '뇌우' | '이슬비' | '강한비' | '어는비', string[]>
|
||||
>;
|
||||
tempNoteSets?: Partial<Record<WeatherBand, string[]>>;
|
||||
/**
|
||||
* 기온대별 한 줄 — **오늘 이 동네에서 뭘 하면 되나.**
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {useEffect, useState, type ReactNode} from 'react';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, channelLabel} from '@site/lib/derive';
|
||||
import {bookingActionLabel, bookingLinks, channelLabel, primaryChannelLink} from '@site/lib/derive';
|
||||
import {formatKoreanDate, isoDate} from '@site/lib/format';
|
||||
// 배럴(@/sections)이 아니라 파일을 직접 가리킨다 — 배럴을 타면 섹션 전부가 딸려 온다.
|
||||
import {MobileTabBar} from '@site/sections/MobileTabBar';
|
||||
@ -22,8 +22,8 @@ export function Shell({children}: {children: ReactNode}) {
|
||||
const index = useSectionIndex();
|
||||
const active = useActiveSection(index);
|
||||
const booking = bookingLinks(payload)[0];
|
||||
// 확정된 링크만 — 확정 전 URL 은 동명 업소일 수 있다.
|
||||
const links = payload.links.filter((link) => link.confirmed);
|
||||
// ★ 확정 채널을 전부 늘어놓지 않는다 — 예약 우선으로 딱 하나만 낸다(seo/jsonld.ts 주석 참고).
|
||||
const link = primaryChannelLink(payload);
|
||||
const address = place.roadAddress ?? place.address;
|
||||
|
||||
return (
|
||||
@ -146,21 +146,19 @@ export function Shell({children}: {children: ReactNode}) {
|
||||
)}
|
||||
</address>
|
||||
|
||||
{links.length > 0 && (
|
||||
{link && (
|
||||
<nav aria-label="공식 채널" className="md:col-span-3">
|
||||
<ul className="space-y-1.5 text-[length:var(--fs-sm)]">
|
||||
{links.map((link) => (
|
||||
<li key={link.url}>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-4 opacity-80 transition-opacity hover:opacity-100"
|
||||
>
|
||||
{channelLabel(link)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-4 opacity-80 transition-opacity hover:opacity-100"
|
||||
>
|
||||
{channelLabel(link)}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
heroFacts,
|
||||
isSectionEnabled,
|
||||
lowestPrice,
|
||||
primaryChannelLink,
|
||||
unitSpec,
|
||||
} from '@site/lib/derive';
|
||||
import {formatKoreanDate, isoDate} from '@site/lib/format';
|
||||
@ -40,9 +41,8 @@ export function Shell({children}: {children: ReactNode}) {
|
||||
const price = lowestPrice(payload);
|
||||
const facts = heroFacts(payload);
|
||||
const booking = bookingLinks(payload)[0];
|
||||
// ★ 확정된 링크 전부다. contactLinks() 는 말을 걸 수 있는 곳(네이버·인스타)만 남기는데,
|
||||
// 이 칼럼은 문의 창구가 아니라 **채널 목록**이라 블로그·공식 채널도 여기 선다.
|
||||
const links = payload.links.filter((link) => link.confirmed);
|
||||
// ★ 확정 채널을 전부 늘어놓지 않는다 — 예약 우선으로 딱 하나만 낸다(seo/jsonld.ts 주석 참고).
|
||||
const link = primaryChannelLink(payload);
|
||||
const address = place.roadAddress ?? place.address;
|
||||
|
||||
const menu = [
|
||||
@ -145,21 +145,19 @@ export function Shell({children}: {children: ReactNode}) {
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{links.length > 0 && (
|
||||
{link && (
|
||||
<nav aria-label="공식 채널" className="mt-12">
|
||||
<ul className="space-y-0.5">
|
||||
{links.map((link) => (
|
||||
<li key={link.url}>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted flex min-h-[2rem] items-center text-[length:var(--fs-xs)] underline-offset-4 transition-colors hover:text-current hover:underline"
|
||||
>
|
||||
{channelLabel(link)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted flex min-h-[2rem] items-center text-[length:var(--fs-xs)] underline-offset-4 transition-colors hover:text-current hover:underline"
|
||||
>
|
||||
{channelLabel(link)}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
@ -15,10 +15,10 @@ import {
|
||||
type WeatherBand,
|
||||
type WeatherMood,
|
||||
} from '@o2o/shared';
|
||||
import {BOOKING_CHANNELS, UNIT_SPEC, publicLinks, unitBaseRate} from '@site/seo/jsonld';
|
||||
import {UNIT_SPEC, bookingChannelLinks, primaryChannelLink, publicLinks, unitBaseRate} from '@site/seo/jsonld';
|
||||
|
||||
// 링크 거르기는 jsonld 에 산다(순환 의존 회피) — 화면 쪽은 늘 derive 를 통해 쓰므로 여기서 다시 낸다.
|
||||
export {publicLinks};
|
||||
export {publicLinks, primaryChannelLink};
|
||||
|
||||
/**
|
||||
* payload → 화면이 바로 쓰는 모양.
|
||||
@ -477,23 +477,10 @@ function confirmedLinks(payload: SitePayload, channels: readonly number[]): Chan
|
||||
* 예약 버튼에 낼 채널. **BOOKING_CHANNELS 순서대로** 정렬한다 — 예약 화면으로 바로 가는
|
||||
* 채널이 맨 위 버튼이어야 한다.
|
||||
*
|
||||
* ★ 검색 결과 주소는 뺀다. 자동 발견이 `map.naver.com/p/search/…` 를 물어오는 경우가 있고
|
||||
* (실측 2026-09-08), 그걸 "예약" 버튼에 걸면 손님이 검색 화면을 만난다 — 예약하러 온
|
||||
* 사람에게 검색 결과를 주는 건 링크가 없는 것보다 나쁘다. 가게를 특정하지 못하는 주소라
|
||||
* 애초에 예약 창구가 아니다.
|
||||
* ★ 필터·순서 판단은 seo/jsonld 에 산다(순환 의존 회피, `bookingChannelLinks`) — 화면과
|
||||
* makesOffer.url·potentialAction 이 같은 함수를 써야 어긋나지 않는다.
|
||||
*/
|
||||
export function bookingLinks(payload: SitePayload): ChannelLink[] {
|
||||
const order = new Map(BOOKING_CHANNELS.map((channel, index) => [channel, index]));
|
||||
return confirmedLinks(payload, BOOKING_CHANNELS)
|
||||
.filter((link) => !isSearchUrl(link.url))
|
||||
.sort((a, b) => (order.get(a.channel) ?? 99) - (order.get(b.channel) ?? 99));
|
||||
}
|
||||
|
||||
/** 가게가 아니라 **검색 결과**를 가리키는 주소인지. */
|
||||
function isSearchUrl(url: string): boolean {
|
||||
return /\/p\/search\/|[?&]query=/.test(url);
|
||||
}
|
||||
|
||||
export const bookingLinks = bookingChannelLinks;
|
||||
|
||||
/**
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
28
solution/site/src/lib/use-live-weather.test.ts
Normal file
28
solution/site/src/lib/use-live-weather.test.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import {expect, it} from 'vitest';
|
||||
import {condition} from './use-live-weather';
|
||||
|
||||
it('recognizes fog, showers and thunderstorms as distinct conditions', () => {
|
||||
expect(condition(45)).toBe('안개');
|
||||
expect(condition(48)).toBe('안개');
|
||||
expect(condition(80)).toBe('소나기');
|
||||
expect(condition(81)).toBe('소나기');
|
||||
expect(condition(82)).toBe('소나기');
|
||||
expect(condition(85)).toBe('소나기');
|
||||
expect(condition(86)).toBe('소나기');
|
||||
expect(condition(95)).toBe('뇌우');
|
||||
expect(condition(96)).toBe('뇌우');
|
||||
expect(condition(99)).toBe('뇌우');
|
||||
});
|
||||
|
||||
it('splits rain into drizzle, normal, heavy and freezing', () => {
|
||||
expect(condition(51)).toBe('이슬비');
|
||||
expect(condition(53)).toBe('이슬비');
|
||||
expect(condition(55)).toBe('이슬비');
|
||||
expect(condition(61)).toBe('비');
|
||||
expect(condition(63)).toBe('비');
|
||||
expect(condition(65)).toBe('강한비');
|
||||
expect(condition(56)).toBe('어는비');
|
||||
expect(condition(57)).toBe('어는비');
|
||||
expect(condition(66)).toBe('어는비');
|
||||
expect(condition(67)).toBe('어는비');
|
||||
});
|
||||
@ -5,11 +5,18 @@ export interface LiveWeather extends WeatherSnapshot {
|
||||
stale?: boolean;
|
||||
}
|
||||
|
||||
function condition(code: number): string {
|
||||
/** WMO weather code → 한 줄. ★ solution/backend/services/site_payload.py 의 _weather_condition() 과 같은 구간이어야 한다. */
|
||||
export function condition(code: number): string {
|
||||
if (code === 0) return '맑음';
|
||||
if (code >= 1 && code <= 3) return '구름많음';
|
||||
if (code >= 50 && code <= 69) return '비';
|
||||
if (code === 45 || code === 48) return '안개';
|
||||
if (code === 51 || code === 53 || code === 55) return '이슬비';
|
||||
if (code === 56 || code === 57 || code === 66 || code === 67) return '어는비';
|
||||
if (code === 61 || code === 63) return '비';
|
||||
if (code === 65) return '강한비';
|
||||
if (code >= 70 && code <= 79) return '눈';
|
||||
if (code === 80 || code === 81 || code === 82 || code === 85 || code === 86) return '소나기';
|
||||
if (code >= 95 && code <= 99) return '뇌우';
|
||||
return '흐림';
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import {ExternalLink, Mail, MapPin, Phone} from 'lucide-react';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {isoDate} from '@site/lib/format';
|
||||
// 채널 이름표는 예약·문의 섹션과 같은 표를 쓴다 — 같은 채널이 자리마다 다른 이름으로 뜨면 안 된다.
|
||||
import {channelLabel, publicLinks} from '@site/lib/derive';
|
||||
import {channelLabel, primaryChannelLink} from '@site/lib/derive';
|
||||
|
||||
/**
|
||||
* 푸터.
|
||||
@ -14,9 +14,8 @@ import {channelLabel, publicLinks} from '@site/lib/derive';
|
||||
export function SiteFooter() {
|
||||
const payload = useSite();
|
||||
const {place, site} = payload;
|
||||
// 확정된 채널만 — 확정 전 URL 은 동명 업소일 수 있다.
|
||||
// ★ sameAs 와 같은 목록이어야 한다 — 화면에 없는 주소가 구조화 데이터에 있으면 발행이 막힌다.
|
||||
const links = publicLinks(payload);
|
||||
// ★ 확정 채널을 전부 늘어놓지 않는다 — 예약 우선으로 딱 하나만 낸다(seo/jsonld.ts 주석 참고).
|
||||
const link = primaryChannelLink(payload);
|
||||
const address = place.roadAddress ?? place.address;
|
||||
|
||||
return (
|
||||
@ -58,23 +57,21 @@ export function SiteFooter() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{links.length > 0 && (
|
||||
{link && (
|
||||
<nav aria-label="공식 채널" className="md:col-span-5">
|
||||
<h2 className="label mb-3 !text-current opacity-60">공식 채널</h2>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{links.map((link) => (
|
||||
<li key={link.url}>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-current/25 px-3 py-2 text-[length:var(--fs-xs)] font-medium transition-colors hover:bg-current/10"
|
||||
>
|
||||
<span>{channelLabel(link)}</span>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-current/25 px-3 py-2 text-[length:var(--fs-xs)] font-medium transition-colors hover:bg-current/10"
|
||||
>
|
||||
<span>{channelLabel(link)}</span>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
@ -36,7 +36,17 @@ export function WeatherSection() {
|
||||
const mood = weatherMood(weather?.condition ?? '');
|
||||
const band = weatherBand(weather?.temperature ?? 0);
|
||||
const notes = payload.local.weather;
|
||||
const skyKey = weather?.condition === '구름많음' ? '구름많음' : mood;
|
||||
const {condition} = weather ?? {};
|
||||
const skyKey =
|
||||
condition === '구름많음' ||
|
||||
condition === '안개' ||
|
||||
condition === '소나기' ||
|
||||
condition === '뇌우' ||
|
||||
condition === '이슬비' ||
|
||||
condition === '강한비' ||
|
||||
condition === '어는비'
|
||||
? condition
|
||||
: mood;
|
||||
// 조건별 문장이 우선. 없으면 예전 한 칸(note)으로 떨어진다 — 옛 payload 도 깨지지 않는다.
|
||||
const line = useWeatherNote(notes?.noteSets?.[skyKey], notes?.notes?.[mood] ?? weather?.note);
|
||||
/*
|
||||
|
||||
@ -223,10 +223,10 @@ describe('예약 버튼은 예약 화면으로 바로 간다', () => {
|
||||
],
|
||||
};
|
||||
|
||||
it('네이버 예약이 플레이스보다 먼저 나온다 — 플레이스는 한 번 더 눌러야 한다', () => {
|
||||
it('네이버 예약이 있으면 네이버 플레이스는 예약 목록에서 빠진다 — 같은 예약이 두 번 뜨지 않는다', () => {
|
||||
const links = stayBookingView(withBooking)!.links;
|
||||
expect(links[0].channel).toBe(LinkChannel.NAVER_BOOKING);
|
||||
expect(links.map((l) => l.channel)).toContain(LinkChannel.NAVER_PLACE);
|
||||
expect(links.map((l) => l.channel)).not.toContain(LinkChannel.NAVER_PLACE);
|
||||
});
|
||||
|
||||
it('버튼 문구가 "네이버 예약에서 예약" 이 되지 않는다', () => {
|
||||
|
||||
@ -19,6 +19,17 @@ it('uses the first sky and temperature lines for deterministic HTML', () => {
|
||||
expect(render(weather)).toBe(render(weather));
|
||||
});
|
||||
|
||||
it('uses condition-specific notes for fog, showers and thunderstorms', () => {
|
||||
const weather = {temperature: 20, observedAt: '2026-09-15T12:00', condition: '소나기', noteSets: {'소나기': ['소나기 첫 줄']}, tempNoteSets: {'선선': ['선선 첫 줄']}};
|
||||
expect(render(weather)).toContain('소나기 첫 줄');
|
||||
});
|
||||
|
||||
it('uses condition-specific notes for the rain subtypes', () => {
|
||||
const weather = {temperature: 5, observedAt: '2026-09-15T12:00', condition: '어는비', noteSets: {'어는비': ['어는비 첫 줄'], '비': ['일반 비 첫 줄']}, tempNoteSets: {'추움': ['추움 첫 줄']}};
|
||||
expect(render(weather)).toContain('어는비 첫 줄');
|
||||
expect(render(weather)).not.toContain('일반 비 첫 줄');
|
||||
});
|
||||
|
||||
it('preserves legacy single notes', () => {
|
||||
const html = render({temperature: 15, observedAt: '2026-09-15T12:00', condition: '비', notes: {'비': '기존 비 안내'}, tempNotes: {'쌀쌀': '겉옷 안내'}});
|
||||
expect(html).toContain('기존 비 안내');
|
||||
|
||||
@ -133,10 +133,13 @@ export function publicLinks(payload: SitePayload): ChannelLink[] {
|
||||
|
||||
/**
|
||||
* 확정된 채널만 sameAs 로 나간다 — 확정 전 URL 은 동명 업소일 수 있다.
|
||||
* ★ 목록은 화면(SiteFooter)과 **같은 함수**에서 온다(`publicLinks`) — 갈리면 게이트가 막는다.
|
||||
* ★ 목록은 화면(SiteFooter)과 **같은 함수**에서 온다(`primaryChannelLink`) — 갈리면 게이트가
|
||||
* 막는다. 화면이 "공식 채널" 하나만 보여주는데 sameAs 가 여럿을 주장하면 그것도 어긋남이다
|
||||
* (2026-09-17).
|
||||
*/
|
||||
function sameAs(payload: SitePayload): string[] {
|
||||
return publicLinks(payload).map((link) => link.url);
|
||||
const link = primaryChannelLink(payload);
|
||||
return link ? [link.url] : [];
|
||||
}
|
||||
|
||||
/** 숙박의 체크인·체크아웃은 Schema.org 에 전용 속성이 있다. 있으면 반드시 채운다. */
|
||||
@ -317,6 +320,53 @@ export const BOOKING_CHANNELS: readonly LinkChannel[] = [
|
||||
LinkChannel.NAVER_PLACE,
|
||||
];
|
||||
|
||||
/** 가게가 아니라 **검색 결과**를 가리키는 주소인지. */
|
||||
function isSearchUrl(url: string): boolean {
|
||||
return /\/p\/search\/|[?&]query=/.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 예약을 실제로 받는 확정 채널 전부, **BOOKING_CHANNELS 순서대로**.
|
||||
*
|
||||
* ★ 검색 결과 주소는 뺀다 — 자동 발견이 `map.naver.com/p/search/…` 를 물어오는 경우가 있고
|
||||
* (실측 2026-09-08), 가게를 특정하지 못하는 주소라 예약 창구가 아니다.
|
||||
* ★ 네이버 예약이 있으면 네이버 플레이스는 뺀다 — 둘 다 남으면 화면에 "네이버 예약"과
|
||||
* "네이버 플레이스에서 예약"이 나란히 떠서 같은 예약이 두 번 있는 것처럼 보인다
|
||||
* (2026-09-17). 네이버 플레이스는 직행 예약 채널이 없을 때만 쓰는 대체 창구다.
|
||||
* ★ 화면(BookingSection 등)과 이 함수를 같이 쓴다 — 갈리면 makesOffer/potentialAction 이
|
||||
* 가리키는 곳과 화면 버튼이 어긋난다.
|
||||
*/
|
||||
export function bookingChannelLinks(payload: SitePayload): ChannelLink[] {
|
||||
const order = new Map(BOOKING_CHANNELS.map((channel, index) => [channel, index]));
|
||||
const links = payload.links.filter(
|
||||
(link) => link.confirmed && BOOKING_CHANNELS.includes(link.channel) && !isSearchUrl(link.url),
|
||||
);
|
||||
const hasNaverBooking = links.some((link) => link.channel === LinkChannel.NAVER_BOOKING);
|
||||
return links
|
||||
.filter((link) => !(hasNaverBooking && link.channel === LinkChannel.NAVER_PLACE))
|
||||
.sort((a, b) => (order.get(a.channel) ?? 99) - (order.get(b.channel) ?? 99));
|
||||
}
|
||||
|
||||
/**
|
||||
* 푸터·판권장의 "공식 채널"에 낼 링크 **딱 하나**. `sameAs` 도 이 함수 하나를 같이 쓴다 —
|
||||
* 갈리면 발행 게이트가 막는다(절대규칙 3, 위 `publicLinks` 주석 참고).
|
||||
*
|
||||
* ★ 왜 하나뿐인가 (2026-09-17)
|
||||
* 확정 채널을 전부 늘어놓으면 야놀자·인스타·네이버플레이스·네이버예약이 나란히 서서
|
||||
* 손님이 어디를 눌러야 할지 고르게 만든다. 예약은 이미 본문·상단 버튼이 답했다 —
|
||||
* 맨 아래는 "여기가 이 업장이 맞다"만 확인해 주면 된다.
|
||||
* ★ 우선순위: 공식 사이트 → 예약 채널(`bookingChannelLinks` 순서) → 그래도 없으면
|
||||
* 확정된 링크 중 첫 번째(2026-09-17 수정: 공식 사이트가 있으면 그게 최우선이다 —
|
||||
* 이 자리는 "업장 대표 주소"를 보증하는 자리지 예약 창구가 아니다).
|
||||
*/
|
||||
export function primaryChannelLink(payload: SitePayload): ChannelLink | undefined {
|
||||
return (
|
||||
payload.links.find((link) => link.confirmed && link.channel === LinkChannel.OFFICIAL_SITE) ??
|
||||
bookingChannelLinks(payload)[0] ??
|
||||
publicLinks(payload)[0]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 예약 화면으로 보낼 URL 하나. **가장 앞선 채널**을 고른다(BOOKING_CHANNELS 순서).
|
||||
*
|
||||
@ -324,11 +374,7 @@ export const BOOKING_CHANNELS: readonly LinkChannel[] = [
|
||||
* 구조화 데이터가 가리키는 곳과 손님이 눌러서 가는 곳이 어긋날 수 없다.
|
||||
*/
|
||||
function bookingChannelUrl(payload: SitePayload): string | undefined {
|
||||
for (const channel of BOOKING_CHANNELS) {
|
||||
const hit = payload.links.find((link) => link.confirmed && link.channel === channel);
|
||||
if (hit) return hit.url;
|
||||
}
|
||||
return undefined;
|
||||
return bookingChannelLinks(payload)[0]?.url;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user