This commit is contained in:
김성경 2026-09-15 17:21:40 +09:00
commit 4cd756108d
10 changed files with 149 additions and 8 deletions

17
docs/WEATHER.md Normal file
View File

@ -0,0 +1,17 @@
# 오늘의 날씨
`Open-Meteo → /v1/local/weather → useLiveWeather → WeatherSection`
관측값은 기존 API를 사용하며 브라우저에서 10분마다 갱신한다. 조회 실패 시 마지막 관측값과
관측 시각을 유지한다. 날씨 문구는 API 요청마다 생성하지 않는다.
`weather_notes.json → weather_notes.py → site_payload._weather → noteSets/tempNoteSets`
하늘 5종·기온 5구간에 각 5문구를 싣는다. 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고,
브라우저에서는 무작위 시작 후 20초마다 한 바퀴 안에서 중복 없이 순환한다.
기온 구간은 기존 `weatherBand`의 30·25·20·10도다. 구름많음은 그림상 흐림과 같지만 문구는 별도다.
목업 README 2.3의 순환 계약을 제품으로 옮겼다. 군산 전용 시설·장소를 다른 사업장에 복사하지
않도록 기본 문구는 장소·시설·영업시간을 주장하지 않는 공통 안내로 구성한다. **LLM 생성이 아니다.**
지역별 장소 추천을 자동 생성하는 작업은 포함하지 않았다. 목업의 수기 문구·산출물은 변경하지 않는다.
옛 단일 `note`·`notes`·`tempNotes` payload도 계속 지원한다. 이미 발행된 사이트는 재발행해야 반영된다.

View File

@ -34,6 +34,7 @@ from common.enums import (
from common.logger import LOG
from services.intro_summary import summarize_intro
from services.stay_guide import nol_stay_guide
from services.weather_notes import weather_notes
# 렌더러가 확인하는 스키마 버전. 모양이 바뀌면 여기와 site-payload.ts 를 같이 올린다.
SCHEMA_VERSION = 1
@ -525,7 +526,7 @@ def _weather(row: dict):
return {
"temperature": temperature,
"condition": _weather_condition(body.get("weather_code")),
# note 는 LLM 이 쓰는 안내 문구다. 지금 그걸 만드는 경로가 없으므로 넣지 않는다(지어내지 않는다).
**weather_notes(),
"observedAt": observed_at,
}

View File

@ -0,0 +1,16 @@
{
"sky": {
"맑음": ["맑은 날에는 잠깐 쉬어 가며 오늘 담은 사진을 골라 보세요.", "분주했던 걸음을 멈추고 느긋하게 쉬어 가도 좋겠습니다.", "오늘 기억하고 싶은 장면을 한 줄 남겨 보세요.", "다음 일정을 서두르기보다 잠시 여유를 가져 보세요.", "좋아하는 책 몇 장을 넘기며 쉬어 가는 건 어떨까요?"],
"구름많음": ["구름이 많은 날에는 실내에서 다음 일정을 천천히 골라 보세요.", "여행 사진을 정리하며 잠깐 쉬어 가세요.", "함께 온 사람과 오늘 가장 좋았던 순간을 이야기해 보세요.", "바깥 일정 사이에 여유로운 휴식 시간을 넣어 보세요.", "오늘의 기분을 짧은 글로 남겨도 좋겠습니다."],
"흐림": ["흐린 날에는 실내에서 느긋하게 쉬어 가도 좋겠습니다.", "잠깐 책을 읽으며 다음 일정을 기다려 보세요.", "바깥 풍경 대신 오늘 나눈 이야기에 집중해 보세요.", "천천히 쉬면서 돌아갈 때 가져갈 추억을 골라 보세요.", "분주한 일정 사이에 조용한 휴식을 끼워 넣어 보세요."],
"비": ["비가 오는 동안에는 실내에서 쉬며 일정을 조정해 보세요.", "서두르지 말고 비가 잦아드는지 확인한 뒤 움직이세요.", "빗소리를 들으며 읽고 싶었던 글을 펼쳐 보세요.", "젖은 물건을 정리하고 잠깐 쉬어 가세요.", "바깥 일정 대신 함께 찍은 사진을 나눠 봐도 좋겠습니다."],
"눈": ["눈 오는 날에는 실내에서 쉬며 바깥 상황을 살펴보세요.", "다음 이동을 서두르지 말고 쉬어 가세요.", "따뜻하게 머물며 오늘의 여행을 한 줄 남겨 보세요.", "실내에서 다음 일정과 이동편을 확인해 보세요.", "눈길을 나서기 전에는 여유 있게 준비하세요."]
},
"temperature": {
"혹서": ["더위가 강한 시간에는 바깥 이동을 줄이고 실내 위주로 일정을 잡아 보세요.", "한낮 산책은 짧게 하고 충분히 쉬어 가세요.", "외출 전 기상특보와 방문할 곳의 운영 여부를 확인하세요.", "다음 장소로 이동할 때에는 쉴 시간을 넉넉히 두세요.", "긴 야외 일정은 미루고 가까운 동선부터 살펴보세요."],
"더움": ["바깥 일정 사이에 실내에서 쉬는 시간을 넣어 보세요.", "외출할 때에는 물을 챙기고 무리한 이동은 줄이세요.", "주변을 둘러보더라도 한 번에 오래 걷지는 마세요.", "방문할 곳의 운영 시간을 먼저 확인하고 이동하세요.", "더위가 느껴지면 일정을 줄이고 쉬어 가세요."],
"선선": ["비나 눈이 없다면 주변을 가볍게 둘러보는 건 어떨까요?", "날씨와 이동 경로를 확인하고 짧은 산책을 계획해 보세요.", "바깥 일정을 잡기 전에 방문할 곳의 운영 여부를 확인하세요.", "편한 신발을 챙기고 자신의 속도로 동네를 둘러보세요.", "비가 오지 않는다면 일정 사이에 잠깐 걸어 보세요."],
"쌀쌀": ["외출할 때에는 걸칠 옷을 챙겨 보세요.", "바깥에 오래 머물기보다 쉬어 갈 곳을 함께 정해 두세요.", "일교차에 대비해 옷차림을 조절하세요.", "이동 경로를 짧게 잡고 실내 일정도 함께 살펴보세요.", "돌아오는 시간의 기온도 확인한 뒤 외출하세요."],
"추움": ["따뜻한 옷을 챙기고 바깥 이동은 짧게 잡아 보세요.", "찬 바람을 피할 수 있는 실내 일정을 살펴보세요.", "출발 전 눈이나 결빙으로 이동이 어려운 곳은 없는지 확인하세요.", "야외에 오래 머무르지 않도록 쉬는 시간을 충분히 두세요.", "방문할 곳의 운영 여부를 확인하고 따뜻하게 준비하세요."]
}
}

View File

@ -0,0 +1,21 @@
"""날씨 안내 계약. 관측값과 분리하고, 확인되지 않은 시설·영업시간은 쓰지 않는다."""
import json
from functools import lru_cache
from pathlib import Path
@lru_cache(maxsize=1)
def _templates() -> dict:
return json.loads(Path(__file__).with_name("weather_notes.json").read_text(encoding="utf-8"))
def weather_notes() -> dict:
templates = _templates()
sky = {key: list(lines) for key, lines in templates["sky"].items()}
temperature = {key: list(lines) for key, lines in templates["temperature"].items()}
return {
"notes": {key: lines[0] for key, lines in sky.items()},
"tempNotes": {key: lines[0] for key, lines in temperature.items()},
"noteSets": sky,
"tempNoteSets": temperature,
}

View File

@ -0,0 +1,24 @@
from services.weather_notes import weather_notes
from services.site_payload import _weather
def test_all_weather_cases_have_five_unique_lines():
notes = weather_notes()
assert set(notes["noteSets"]) == {"맑음", "구름많음", "흐림", "", ""}
assert set(notes["tempNoteSets"]) == {"혹서", "더움", "선선", "쌀쌀", "추움"}
lines = [line for key in ("noteSets", "tempNoteSets") for group in notes[key].values() for line in group]
assert len(lines) == len(set(lines)) == 50
def test_templates_are_not_mutated_by_a_payload():
notes = weather_notes()
notes["noteSets"][""].clear()
assert len(weather_notes()["noteSets"][""]) == 5
def test_payload_attaches_notes_only_to_real_observations():
assert _weather({"body": {}}) is None
weather = _weather({"body": {"temperature": 0, "weather_code": 71, "observed_at": "2026-09-15T12:00"}})
assert weather["condition"] == ""
assert weather["temperature"] == 0
assert len(weather["tempNoteSets"]["추움"]) == 5

View File

@ -287,6 +287,8 @@ export interface WeatherSnapshot {
* .
*/
notes?: Partial<Record<WeatherMood, string>>;
noteSets?: Partial<Record<WeatherMood | '구름많음', string[]>>;
tempNoteSets?: Partial<Record<WeatherBand, string[]>>;
/**
* ** .**
*

View File

@ -1230,6 +1230,6 @@ ssh King_admin "rm -rf ~/data2/o2o-site-AEO/tmp-stay-deploy"
| 렌더러의 카로셀 자동 넘김 | 운영 사이트에 그대로 있다. 고치려면 `use-rail-autoplay.ts` 를 고치고 **전체 재굽기 + `republish_all.py`** |
| ~~일정 정거장의 체류 대비 이동~~ | **해결(2026-09-11)** — 규칙 15·16 을 감사에 넣었다. 전수 검수 21/21 (`SCHEDULE.md`) |
| 렌더러의 가요 다방 판 배치 | 운영 사이트도 `flex-wrap` 이라 곡이 늘면 진열대가 된다(`SongsSection.tsx`). 목업은 CSS 로 덮었다(**2.5** 7). 제품에 옮기려면 **전체 재굽기 + `republish_all.py`** |
| 날씨 문구를 만드는 경로 | 제품에는 없다 — `site_payload.py` `_weather()` 는 기온·하늘·관측시각만 싣는다. `notes`·`tempNotes` 는 이 목업에서 손으로 적은 것이고, 사이트마다 자동으로 나오려면 생성 잡이 하나 필요하다 |
| 날씨 문구를 만드는 경로 | 2026-09-15: 제품에 공통 안내 50개를 읽어 `noteSets`·`tempNoteSets`로 싣고 20초마다 순환하는 경로를 추가했다([WEATHER.md](../../../../docs/WEATHER.md)). 이 목업의 군산 전용 문구는 그대로다. 지역별 장소 추천을 자동 생성하는 잡은 아직 없다 |
| 오늘의 한 장 39장 (365일 중 39일) | 확인되는 소재가 거기까지다. 받은 `gunsan_365_story_db.xlsx` 는 365행이지만 실제 주제가 52개(한 주제를 운영 슬롯 7가지로 복제)이고 231행이 `검증필요`, 시·수필은 빈 슬롯이다. 더 채우려면 **군산을 소재로 한 시·수필을 출처와 함께 찾아야** 한다 |
| 일정을 이미지/링크로 공유 | 이미지 저장은 가능(카드를 PNG 로). 링크는 지금 섹션까지만 간다 — 탭·코스가 URL 에 안 남는다 |

View File

@ -0,0 +1,28 @@
import {useEffect, useState} from 'react';
/** 첫 렌더는 고정해 SSR과 맞추고, 이후에는 한 바퀴 안에서 중복 없이 돌린다. */
export function useWeatherNote(lines: string[] | undefined, fallback?: string) {
const [selection, setSelection] = useState<{lines: string[]; index: number}>();
useEffect(() => {
if (!lines?.length) return;
let remaining: number[] = [];
let previous = -1;
const next = () => {
if (!remaining.length) remaining = lines.map((_, index) => index);
const choices = remaining.filter((index) => index !== previous);
const available = choices.length ? choices : remaining;
const index = available[Math.floor(Math.random() * available.length)];
remaining = remaining.filter((value) => value !== index);
previous = index;
setSelection({lines, index});
};
// effect 안의 동기 setState 대신 첫 타이머에서 무작위 문구로 전환한다.
const first = window.setTimeout(next, 0);
const timer = window.setInterval(next, 20_000);
return () => {
window.clearTimeout(first);
window.clearInterval(timer);
};
}, [lines]);
return lines?.[selection?.lines === lines ? selection.index : 0] ?? fallback;
}

View File

@ -11,6 +11,7 @@
*/
import {useSite} from '@site/lib/site-context';
import {useLiveWeather} from '@site/lib/use-live-weather';
import {useWeatherNote} from '@site/lib/use-weather-note';
import {weatherBand, weatherMood} from '@site/lib/derive';
import {Card, Section} from '@site/lib/ui';
import './weather.css';
@ -32,12 +33,12 @@ export function WeatherSection() {
longitude: payload.place.longitude,
});
if (!weather) return null;
const mood = weatherMood(weather.condition);
const band = weatherBand(weather.temperature);
const mood = weatherMood(weather?.condition ?? '');
const band = weatherBand(weather?.temperature ?? 0);
const notes = payload.local.weather;
const skyKey = weather?.condition === '구름많음' ? '구름많음' : mood;
// 조건별 문장이 우선. 없으면 예전 한 칸(note)으로 떨어진다 — 옛 payload 도 깨지지 않는다.
const line = payload.local.weather?.notes?.[mood] ?? weather.note;
const line = useWeatherNote(notes?.noteSets?.[skyKey], notes?.notes?.[mood] ?? weather?.note);
/*
* (2026-09-09, : "기온에 따라서 문구 다르게, 펜션지역의 관광에 맞춰서")
* (·CAFÉ) "그래서 오늘 어디를 가지" .
@ -45,7 +46,8 @@ export function WeatherSection() {
*
* , .
*/
const bandLine = payload.local.weather?.tempNotes?.[band];
const bandLine = useWeatherNote(notes?.tempNoteSets?.[band], notes?.tempNotes?.[band]);
if (!weather) return null;
const observed = observedText(weather.observedAt);
return (

View File

@ -0,0 +1,30 @@
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 {WeatherSection} from './WeatherSection';
import type {WeatherSnapshot} from '@o2o/shared';
function render(weather?: WeatherSnapshot) {
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
payload.local.weather = weather;
return renderToStaticMarkup(<SiteProvider payload={payload}><WeatherSection /></SiteProvider>);
}
it('uses the first sky and temperature lines for deterministic HTML', () => {
const weather = {temperature: 30, observedAt: '2026-09-15T12:00', condition: '구름많음', noteSets: {'구름많음': ['구름 첫 줄', '다음 줄']}, tempNoteSets: {'혹서': ['더위 첫 줄']}};
expect(render(weather)).toContain('구름 첫 줄');
expect(render(weather)).toContain('더위 첫 줄');
expect(render(weather)).not.toContain('다음 줄');
expect(render(weather)).toBe(render(weather));
});
it('preserves legacy single notes', () => {
const html = render({temperature: 15, observedAt: '2026-09-15T12:00', condition: '비', notes: {'비': '기존 비 안내'}, tempNotes: {'쌀쌀': '겉옷 안내'}});
expect(html).toContain('기존 비 안내');
expect(html).toContain('겉옷 안내');
});
it('does not invent weather when an observation is missing', () => {
expect(render()).toBe('');
});