From b2c8bb033ee173c2bae9f952bf31bd2da7f8c79b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Mon, 14 Sep 2026 14:38:24 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[feat]=20solution/backend,site:=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=20=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EC=A0=9C=EB=AA=A9?= =?UTF-8?q?=C2=B7keywords=20=EB=A9=94=ED=83=80=EC=97=90=20SiteOntology=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=E2=80=94=20=EC=9D=B4=20=EA=B0=80?= =?UTF-8?q?=EA=B2=8C=20=EC=9E=90=EB=A3=8C=EB=A1=9C=20=EA=B1=B0=EB=A5=B8=20?= =?UTF-8?q?=EA=B2=83=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를 받고, 이 가게의 확인된 자료로 거른 것만 `` 와 제목 업종어 자리에 싣는다. 실측(2026-09-14, 스테이머뭄 프로필): 추천 10건 중 `군산 독채 마당 펜션`·`군산 독채 복층 펜션`· `군산 커플 프라이빗 펜션` 이 status=ok 로 왔다 — SiteOntology 사실 필터는 수용 인원과 일부 시설만 본다. 사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다. 메타 태그와 제목은 AI 검색이 그대로 읽는 자리라, 키워드의 모든 낱말이 이 가게 자료에 있을 때만 싣는다. SiteOntology 쪽 함정도 실측으로 막았다 — 없는 regionId 는 500(외래키), 해석 안 된 query 도 201 로 입력 문자열 검색 결과를 준다. - services/external/site_ontology.py: publish(generate:false) → match 두 번 호출. 500 이면 지역 없이 재시도, resolved 가 우리 place_id 가 아니면 실패로 본다 - services/seo_keywords.py: 스냅샷 → 프로필(있음=features · 없음=뺌 · 모름=unverified), 낱말 대조 거르기, 업종어뿐인 단어 제외, 제목은 유형 레인 코어 중 시·군 이름을 품고 예약·추천이 없는 것. 숙박만 - services/build_service.py: 스냅샷 직후 호출해 snapshot["seo"] 에 싣는다(= 발행 기록). 실패해도 발행 계속 - services/site_payload.py · shared site-payload.ts: 선택 필드 `seo` — 옛 payload·목업은 그대로 - site/src/seo/meta.ts · head.ts: 제목 `<상호> · <대표 키워드>`(15자 미만이면 예전 제목), keywords 태그는 키워드가 있을 때만(빈 태그를 만들지 않는다) - config_models.py · .env.example: SITE_ONTOLOGY_URL — 비우면 호출하지 않는다 - docs/ARCHITECTURE.md 발행 파이프라인 · docs/DEVLOG.md pytest tests/test_seo_keywords.py 16 passed · 백엔드 전체 650 passed(실패 2건은 이전부터: test_rate_limit_closes_the_tap · test_사이트_디렉터리_밖의_thumbs_에_올린다) site tsc·eslint 통과 · vitest 63 passed · frontend·admin tsc 통과 실제 발행 한 바퀴(로컬 SiteOntology :3100): 스테이머뭄 → `스테이머뭄 · 군산 독채펜션` + keywords 9건, 렌더 게이트 불일치 0건. SiteOntology 없는 payload 는 제목·head 가 예전 그대로 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LBR4o9Nth3g4eoQEnL1iat --- .env.example | 5 + docs/ARCHITECTURE.md | 5 +- docs/DEVLOG.md | 31 ++ solution/backend/config/config_models.py | 3 + solution/backend/services/build_service.py | 27 +- .../services/external/site_ontology.py | 87 +++++ solution/backend/services/seo_keywords.py | 294 ++++++++++++++++ solution/backend/services/site_payload.py | 17 + solution/backend/tests/test_seo_keywords.py | 321 ++++++++++++++++++ solution/shared/src/types/site-payload.ts | 15 + solution/site/src/seo/head.ts | 7 + solution/site/src/seo/meta.test.ts | 50 +++ solution/site/src/seo/meta.ts | 22 ++ 13 files changed, 882 insertions(+), 2 deletions(-) create mode 100644 solution/backend/services/external/site_ontology.py create mode 100644 solution/backend/services/seo_keywords.py create mode 100644 solution/backend/tests/test_seo_keywords.py create mode 100644 solution/site/src/seo/meta.test.ts diff --git a/.env.example b/.env.example index 045f3f4..2491fc8 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,11 @@ TOUR_API_KEY= # 그래도 API 가 필수로 요구하는 필드라 값을 채워 보낸다. SUNO_API_KEY= SUNO_CALLBACK_URL=https://example.com/api/suno/callback +# 발행 사이트 메타 키워드(keywords · 제목)를 받아 올 SiteOntology 주소(o2o-site-ontology, 기본 :3100). +# 비우면 그 단계만 건너뛴다 — 제목·메타가 예전 그대로 나간다. +# ★ 워커가 부르는 주소다. compose 로 띄우면 컨테이너 안에서 보는 주소(http://host.docker.internal:3100), +# 백엔드를 네이티브로 돌리면 http://127.0.0.1:3100 +SITE_ONTOLOGY_URL= # 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 46ef371..1d7f7f3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -37,7 +37,10 @@ React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포 ``` BUILD 잡 (worker) ─ services/build_service.py:99 run_build() - ├ build_snapshot → site_versions 행 insert (원본 데이터, JSONB) + ├ build_snapshot → 원본 데이터(JSONB) + ├ seo_keywords.fetch() → SiteOntology 추천을 이 가게 자료로 거른 키워드 → snapshot["seo"] + │ (숙박만 · 설정 없거나 실패하면 생략 · 발행은 계속) + │ → site_versions 행 insert ├ 1차 게이트 (DB 사실 기준) → publish_gate.evaluate() ├ site_payload.emit_payload() → out/payloads/.json ★ 백엔드의 유일한 산출물 │ diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 1b63c4d..3e1bcf1 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,37 @@ --- +## 2026-09-14 — 발행 사이트 제목·keywords 메타에 SiteOntology 키워드를 싣는다 + +**무슨 일** — 숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를 +받고, 거른 결과를 `` 와 제목 업종어 자리에 싣는다. +``` +스냅샷 → 프로필(확인된 fact · 주소 · 발행되는 주변 관광지) + → POST /v1/merchants/publish (generate:false) → POST /v1/match (query=place_id) + → 거르기 → snapshot["seo"] → payload.seo + → 스테이,머뭄 · 군산 독채펜션 · +``` + +**★ 거르기가 필요한 이유 (실측)** — 스테이머뭄 프로필로 받은 추천 10건 중 `군산 독채 마당 펜션`· +`군산 독채 복층 펜션`·`군산 커플 프라이빗 펜션` 이 status=ok 로 왔다. SiteOntology 의 사실 필터는 수용 인원과 +일부 시설만 보기 때문이다. 사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다. +→ **키워드의 모든 낱말이 이 가게 자료에 있어야** 싣는다. 이 규칙 하나로 셋이 같이 걸리고, 10건이 4건이 됐다. + 제목에는 `예약`·`추천` 이 붙은 것과 시·군 이름이 없는 것도 뺀다. 규칙의 단일 출처는 `services/seo_keywords.py`. + +**★ SiteOntology 쪽 함정 (실측)** +- region 표에 없는 `regionId` 를 보내면 **500**(외래키 위반). 표 내용은 적재한 데이터셋에 따라 달라 우리가 모른다 + → 500 이면 지역 없이 한 번 더 보낸다. +- 해석되지 않은 `query` 에도 **201** 로 입력 문자열 검색 결과를 준다(`나운동 숙소` …) → `resolved` 가 + 우리 place_id 가 아니면 버린다. + +**경계** — SiteOntology 는 **수정하지 않았다**. 설정(`SITE_ONTOLOGY_URL`)이 비면 호출하지 않고, 실패하면 +키워드 없이 예전 제목으로 발행한다. 키워드는 스냅샷에 실려 `site_versions.snapshot` 이 곧 발행 기록이다. + +**남은 것** — SiteOntology 매칭은 지역으로 거르지 않는다. 지금은 낱말 대조가 다른 지역 단어를 막지만, +운영에 전국 데이터가 들어가면 SiteOntology 에 지역 필터를 넣는 것이 맞다. + +--- + ## 2026-09-11 — 발행하면 이 숙소의 노래가 한 곡 생긴다 (가사 Gemini → 작곡 Suno) **무슨 일** — `/s/stay` 시안에는 헤더에 노래 플레이어가 있는데, 그건 손으로 채운 목업이라 diff --git a/solution/backend/config/config_models.py b/solution/backend/config/config_models.py index 245856e..70e692c 100644 --- a/solution/backend/config/config_models.py +++ b/solution/backend/config/config_models.py @@ -136,6 +136,9 @@ class ExternalApiConfig(BaseSettings): # ★ 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다. # 그래도 API 가 필수로 요구하는 필드라 값을 들고 있는다(services/external/suno.py 주석). suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL") + # 발행 사이트 메타 키워드(keywords · 제목)를 받아 오는 사내 서비스(o2o-site-ontology). 비면 그 단계만 + # 건너뛴다 — 제목·메타가 예전 그대로 나간다(services/seo_keywords). + site_ontology_url: str = Field("", validation_alias="SITE_ONTOLOGY_URL") # .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다. diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index 89ced65..e6a5854 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -29,7 +29,16 @@ from common.logger import LOG from common.utils.gtime import GTime from crud.site_crud import SiteCRUD from crud.place_crud import PlaceCRUD -from services import azure_static, indexnow, publish_gate, render_report, site_payload, site_thumbnail, song_service +from services import ( + azure_static, + indexnow, + publish_gate, + render_report, + seo_keywords, + site_payload, + site_thumbnail, + song_service, +) from services.local_content_service import LocalContentService from services.site_payload import emit_payload from services.snapshot import build_snapshot @@ -157,6 +166,19 @@ async def run_build(job: dict) -> dict: snapshot = await build_snapshot(place) + # ★ 메타 태그용 검색 키워드(SiteOntology). **스냅샷에 싣는다** — payload 는 스냅샷만 보고 만들고, + # "이 버전에 어떤 키워드가 나갔나" 가 site_versions.snapshot 에 남는다(services/seo_keywords 머리주석). + # ★ 실패해도 빌드는 계속한다. 주변 정보·노래와 같은 규칙이다 — 키워드 없이 예전 제목·메타로 나간다. + # ★ 재빌드(publish=False)에도 부른다. 로컬 임베딩이라 비용이 없고, 재빌드한 버전과 발행한 버전의 + # 제목이 갈리면 "눌러 본 것과 나간 것이 다르다" 가 된다. + seo: dict | None = None + try: + seo = await seo_keywords.fetch(place_id, snapshot) + except Exception as ex: # noqa: BLE001 — 키워드 실패가 발행을 죽이면 안 된다 + LOG.w(f"[build] place={place_id} 검색 키워드 실패(키워드 없이 발행): {type(ex).__name__}: {ex}") + if seo: + snapshot["seo"] = seo + v_err, version_no = await DB_SESSION_MNG.execute_lambda( site_versions.DBType(), DBWRType.DB_READ.value, lambda s: _site_crud.next_version_no(s, site.site_id) ) @@ -180,6 +202,9 @@ async def run_build(job: dict) -> dict: # 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다. if song_result is not None: result["song"] = song_result + # "제목이 왜 예전 그대로인가" 도 같다 — 키워드가 실렸으면 잡 결과에 보인다(없으면 로그의 [seo] 줄). + if seo is not None: + result["seo"] = seo now = GTime.UTC() async def _fail(reason: str, gate: publish_gate.GateResult | None = None, extra: dict | None = None): diff --git a/solution/backend/services/external/site_ontology.py b/solution/backend/services/external/site_ontology.py new file mode 100644 index 0000000..03ab606 --- /dev/null +++ b/solution/backend/services/external/site_ontology.py @@ -0,0 +1,87 @@ +"""SiteOntology(o2o-site-ontology) 클라이언트 — 이 가게에 맞는 검색 키워드를 받아 온다. + +SiteOntology 는 펜션 SEO/AEO 키워드 사전(pgvector)을 들고, 업체 프로필에 맞는 단어를 골라 주는 +사내 서비스다. 여기서는 창구 두 개만 쓴다(실측 2026-09-14, 로컬 :3100). + + POST /v1/merchants/publish {externalId, name, industryId, regionId, description, profile, generate:false} + → 201 {merchant, generation: "skipped"} 업체를 저장만 한다. 키워드는 주지 않는다 + POST /v1/match {query: , limit} + → 201 {resolved, lanes, matches[], byLane[], excluded[]} 저장된 프로필로 추천한다 + +★ 두 번 부르는 이유: "프로필을 주면 키워드를 돌려주는" 창구가 한 번에는 없다. match 는 query 문자열만 + 받고, externalId 를 넣으면 저장된 업체로 해석해 그 프로필로 추천한다. 그래서 먼저 publish 로 프로필을 + 이번 빌드의 스냅샷 값으로 맞춘다. +★ generate:false 를 반드시 보낸다. 빠뜨리면 SiteOntology 가 LLM 키워드 생성을 큐에 넣는다 — + 그 결과는 우리가 쓰지 않는 창구(/seo)로만 나가고, 로컬은 mock LLM 이라 가짜 단어가 사전에 쌓인다. +★ regionId 가 SiteOntology 의 region 표에 없으면 **500** 이다(외래키 위반, 실측). 그 표는 적재한 데이터셋에 + 따라 달라서(군산만 / 전국 54개) 우리가 알 수 없다 — 500 이면 regionId 를 비워 한 번 더 보낸다. + 지역이 비면 유형 추천에서 "군산" 이 빠져 품질이 떨어지지만(실측: 1위가 `독채펜션`), 없는 것보다 낫고 + 지역 없는 단어는 호출측 거르기(seo_keywords)가 제목에서 뺀다. +★ externalId 가 해석되지 않으면 match 는 에러가 아니라 **입력 문자열 자체로 검색한 결과**를 201 로 준다 + (실측: "no-such-place-id" → `나운동 숙소`·`선유도 숙소 예약 언제 해야 하나요`). 그걸 쓰면 남의 동네 단어가 + 나간다 — resolved 가 우리 externalId 가 아니면 실패로 본다. +★ 실패는 발행을 막지 않는다. 여기서는 SiteOntologyError 로 올리고, 호출측이 잡아 키워드 없이 굽는다. +""" + +import httpx + +from common.logger import LOG +from config.server_configs import external_api_config + +# 로컬 임베딩 모델이라 호출당 1~2초다. 넉넉히 잡되 발행 잡 데드라인(900s)을 잡아먹지 않게 끊는다. +TIMEOUT_SEC = 15.0 +# 거르기(seo_keywords)에서 절반 넘게 떨어진다(실측: 10건 → 4건). 메타 태그 10개를 채우려면 넉넉히 받는다. +DEFAULT_LIMIT = 40 + +PUBLISH_PATH = "/v1/merchants/publish" +MATCH_PATH = "/v1/match" + + +class SiteOntologyError(RuntimeError): + """SiteOntology 호출 실패(네트워크·타임아웃·4xx/5xx·업체 미해석). 발행은 계속된다.""" + + +def base_url() -> str: + return (external_api_config.site_ontology_url or "").strip().rstrip("/") + + +def is_configured() -> bool: + return bool(base_url()) + + +async def _post(client: httpx.AsyncClient, path: str, body: dict) -> dict: + res = await client.post(f"{base_url()}{path}", json=body) + if res.status_code >= 400: + raise SiteOntologyError(f"{path} HTTP {res.status_code}: {res.text[:200]}") + try: + data = res.json() + except ValueError as ex: + raise SiteOntologyError(f"{path} 응답이 JSON 이 아니다: {res.text[:200]}") from ex + if not isinstance(data, dict): + raise SiteOntologyError(f"{path} 응답 모양이 다르다: {type(data).__name__}") + return data + + +async def match_for_merchant(merchant: dict, limit: int = DEFAULT_LIMIT) -> dict: + """업체를 저장하고, 그 업체로 해석된 추천 결과(/v1/match 응답)를 돌려준다. + + merchant 는 publish 요청 본문이다(generate 는 여기서 붙인다). 실패하면 SiteOntologyError.""" + body = {**merchant, "generate": False} + try: + async with httpx.AsyncClient(timeout=TIMEOUT_SEC) as client: + try: + await _post(client, PUBLISH_PATH, body) + except SiteOntologyError as ex: + if not body.get("regionId"): + raise + LOG.w(f"[site-ontology] regionId={body['regionId']} 로 저장하지 못했다 — 지역 없이 다시 보낸다: {ex}") + body = {**body, "regionId": None} + await _post(client, PUBLISH_PATH, body) + result = await _post(client, MATCH_PATH, {"query": merchant["externalId"], "limit": limit}) + except httpx.HTTPError as ex: + raise SiteOntologyError(f"{type(ex).__name__}: {ex}") from ex + + resolved = result.get("resolved") or {} + if resolved.get("externalId") != merchant["externalId"]: + raise SiteOntologyError(f"업체가 해석되지 않았다 — externalId={merchant['externalId']}") + return result diff --git a/solution/backend/services/seo_keywords.py b/solution/backend/services/seo_keywords.py new file mode 100644 index 0000000..a9d2e13 --- /dev/null +++ b/solution/backend/services/seo_keywords.py @@ -0,0 +1,294 @@ +"""발행 사이트 메타 태그용 검색 키워드 — SiteOntology 추천을 이 가게의 확인된 자료로 한 번 더 거른다. + + 스냅샷 → 업체 프로필(build_merchant) → SiteOntology publish + match → 거르기(select) + → snapshot["seo"] → payload.seo → · 의 업종어 자리 + +★ 스냅샷에 싣는다(site_versions.snapshot). payload 는 스냅샷만 보고 만든다는 원칙(site_payload 머리주석)을 + 그대로 지키고, "이 버전에 어떤 키워드가 나갔나" 가 발행 기록으로 남는다. SiteOntology 쪽에는 남기지 않는다. +★ 숙박만 부른다. SiteOntology 사전은 펜션 키워드뿐이다(2026-09-14 기준 industry=stay.pension). + 다른 업종으로 부르면 펜션 단어가 카페 사이트의 메타 태그에 붙는다. + +★ 거르기 규칙은 하나다 — **키워드의 모든 낱말이 이 가게의 확인된 자료에 있어야 한다.** + SiteOntology 의 사실 필터는 수용 인원과 일부 시설(바베큐·수영장·스파…)만 본다. 실측(2026-09-14, 스테이머뭄 + 프로필)에서 `군산 독채 마당 펜션`·`군산 독채 복층 펜션` 이 status=ok 로 왔다 — 마당·복층은 확인된 적이 없다. + 사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다. 낱말 대조 하나로 셋이 함께 걸린다. + 메타 태그와 제목은 AI 검색이 그대로 읽는 자리라, 확인 안 된 시설을 광고하는 단어는 지어낸 문장과 같다 + (solution/site/src/seo/meta.ts 머리주석 — description 은 지어내지 않는다). + "자료" 는 상호·소개문·주소·확인된 시설/객실 fact·발행되는 주변 관광지다. 미확인(unverified)은 자료가 아니다. +""" + +import re +import unicodedata + +from common.enums import LocalContentType, PlaceCategory +from common.logger import LOG +from services.external import site_ontology +from services.site_payload import _parse_address_parts + +LODGING_INDUSTRY = "stay.pension" +MAX_KEYWORDS = 10 +MAX_NEARBY = 8 + +# ★ SiteOntology 의 시설 어휘(match.rules.ts AMENITY_SYNONYMS)로 옮긴다 — 저쪽이 이 낱말로 보유 시설을 판정한다. +# 값이 "true" 면 있음 → features, "false" 면 **없음** → 어디에도 넣지 않는다(그래야 저쪽이 그 시설 키워드를 배제한다), +# fact 가 아예 없으면 **모름** → unverified(저쪽이 배제하지 않고 보류한다 — 없음과 모름은 다르다). +_AMENITY_FACTS = { + "bbq_available": "바베큐", + "parking": "주차", + "pet_allowed": "애견동반", + "breakfast": "조식", +} +# 사장님이 확인한 글자 그대로 싣는 fact(객실 단위 포함). "독채"·"오션뷰" 같은 유형·전망어가 여기서 온다. +_FEATURE_TEXT_KEYS = ("room_type", "building_scale", "facilities", "view") +_CAPACITY_KEYS = ("max_capacity", "accommodation_capacity") + +# (ISO 3166-2 시·도, 시·군) → SiteOntology region 키(data/regions.json 54개). +# ★ 시·도를 함께 본다 — 고성군은 강원과 경남에 둘 다 있다. +# ★ 을왕리(kr.incheon.yeongjong)는 넣지 않는다. 행정구역으로는 인천 중구인데 중구 전체를 을왕리로 보낼 수 없다. +_REGION_KEYS = { + ("KR-41", "가평군"): "kr.gyeonggi.gapyeong", ("KR-41", "양평군"): "kr.gyeonggi.yangpyeong", + ("KR-41", "포천시"): "kr.gyeonggi.pocheon", ("KR-41", "파주시"): "kr.gyeonggi.paju", + ("KR-28", "강화군"): "kr.incheon.ganghwa", + ("KR-42", "춘천시"): "kr.gangwon.chuncheon", ("KR-42", "홍천군"): "kr.gangwon.hongcheon", + ("KR-42", "인제군"): "kr.gangwon.inje", ("KR-42", "평창군"): "kr.gangwon.pyeongchang", + ("KR-42", "정선군"): "kr.gangwon.jeongseon", ("KR-42", "강릉시"): "kr.gangwon.gangneung", + ("KR-42", "속초시"): "kr.gangwon.sokcho", ("KR-42", "양양군"): "kr.gangwon.yangyang", + ("KR-42", "고성군"): "kr.gangwon.goseong", ("KR-42", "동해시"): "kr.gangwon.donghae", + ("KR-42", "삼척시"): "kr.gangwon.samcheok", ("KR-42", "태백시"): "kr.gangwon.taebaek", + ("KR-43", "단양군"): "kr.chungbuk.danyang", ("KR-43", "제천시"): "kr.chungbuk.jecheon", + ("KR-43", "충주시"): "kr.chungbuk.chungju", ("KR-43", "괴산군"): "kr.chungbuk.goesan", + ("KR-44", "태안군"): "kr.chungnam.taean", ("KR-44", "보령시"): "kr.chungnam.boryeong", + ("KR-44", "서산시"): "kr.chungnam.seosan", ("KR-44", "공주시"): "kr.chungnam.gongju", + ("KR-44", "부여군"): "kr.chungnam.buyeo", + ("KR-45", "군산시"): "kr.jeonbuk.gunsan", ("KR-45", "부안군"): "kr.jeonbuk.buan", + ("KR-45", "전주시"): "kr.jeonbuk.jeonju", ("KR-45", "무주군"): "kr.jeonbuk.muju", + ("KR-45", "남원시"): "kr.jeonbuk.namwon", + ("KR-46", "여수시"): "kr.jeonnam.yeosu", ("KR-46", "순천시"): "kr.jeonnam.suncheon", + ("KR-46", "담양군"): "kr.jeonnam.damyang", ("KR-46", "구례군"): "kr.jeonnam.gurye", + ("KR-46", "해남군"): "kr.jeonnam.haenam", ("KR-46", "완도군"): "kr.jeonnam.wando", + ("KR-46", "보성군"): "kr.jeonnam.boseong", + ("KR-47", "경주시"): "kr.gyeongbuk.gyeongju", ("KR-47", "포항시"): "kr.gyeongbuk.pohang", + ("KR-47", "안동시"): "kr.gyeongbuk.andong", ("KR-47", "영덕군"): "kr.gyeongbuk.yeongdeok", + ("KR-47", "울진군"): "kr.gyeongbuk.uljin", ("KR-47", "문경시"): "kr.gyeongbuk.mungyeong", + ("KR-48", "거제시"): "kr.gyeongnam.geoje", ("KR-48", "통영시"): "kr.gyeongnam.tongyeong", + ("KR-48", "남해군"): "kr.gyeongnam.namhae", ("KR-48", "하동군"): "kr.gyeongnam.hadong", + ("KR-48", "사천시"): "kr.gyeongnam.sacheon", ("KR-48", "산청군"): "kr.gyeongnam.sancheong", + ("KR-26", "기장군"): "kr.busan.gijang", + ("KR-49", "제주시"): "kr.jeju.jejusi", ("KR-49", "서귀포시"): "kr.jeju.seogwipo", +} + +# 자료에 없어도 되는 낱말 — 업종어와 "근처" 류. 무엇을 주장하지 않는다. +_GENERIC_WORDS = frozenset({"펜션", "숙소", "숙박", "스테이", "근처", "가까운", "주변", "인근", "예약", "추천"}) +# "독채펜션"·"감성숙소" 처럼 붙여 쓴 업종어는 떼고 앞부분만 자료와 대조한다. +_GENERIC_SUFFIXES = ("펜션", "숙소", "스테이") +# 제목에는 싣지 않는 낱말. `스테이,머뭄 · 군산 독채펜션 예약` 은 검색어로는 맞아도 가게 이름 옆에서는 광고 문구다. +_TITLE_BLOCKED_WORDS = frozenset({"예약", "추천"}) +# 한 글자 낱말("봄"·"뷰")은 소개문 어딘가에 우연히 들어 있어 대조가 무의미하다 — 통과시키지 않는다. +_MIN_CORE_LEN = 2 + +_NON_WORD = re.compile(r"[^\w]") + + +def _text(value) -> str: + return str(value).strip() if value is not None else "" + + +def _compact(text: str) -> str: + """대조용 표기 — 공백·구두점을 지우고 소문자로. "스테이,머뭄" 과 "스테이 머뭄" 이 같아진다.""" + return _NON_WORD.sub("", unicodedata.normalize("NFKC", text or "").lower()) + + +def _number(value) -> int | None: + try: + return int(float(value)) + except (TypeError, ValueError): + return None + + +def region_key(*addresses: str | None) -> str | None: + """주소 → SiteOntology region 키. 표에 없으면 None(지어내지 않는다).""" + parts = _parse_address_parts(*addresses) + locality = (parts.get("addressLocality") or "").split() + if not locality: + return None + return _REGION_KEYS.get((parts["addressRegionCode"], locality[0])) + + +def _locality_word(*addresses: str | None) -> str: + """사람이 검색창에 치는 시·군 이름 — "군산시" → "군산". 제목 키워드가 이 낱말을 품어야 한다.""" + locality = (_parse_address_parts(*addresses).get("addressLocality") or "").split() + if not locality: + return "" + word = locality[0] + return word[:-1] if len(word) > 2 and word.endswith(("시", "군", "구")) else word + + +def build_merchant(place_id: str, snapshot: dict) -> dict | None: + """스냅샷 → SiteOntology publish 요청 본문. 숙박이 아니거나 상호가 없으면 None. + + ★ 스냅샷만 읽는다 — 스냅샷에는 노출 가능한 값(VERIFIED/CORRECTED fact · 발행 창 안의 지역 정보)만 있다. + 미검증 fact 를 프로필에 실으면 그걸 근거로 고른 키워드가 메타 태그로 나간다.""" + place = (snapshot or {}).get("place") or {} + if _number(place.get("category")) != PlaceCategory.LODGING.value: + return None + name = _text(place.get("name")) + if not name: + return None + + values: dict[str, list[str]] = {} + for fact in (snapshot or {}).get("facts") or []: + if not isinstance(fact, dict): + continue + value = _text(fact.get("value")) + if value: + values.setdefault(_text(fact.get("key")), []).append(value) + + features: list[str] = [] + unverified: list[str] = [] + for key, label in _AMENITY_FACTS.items(): + found = values.get(key) + if not found: + unverified.append(label) + elif "true" in found: + features.append(label) + for key in _FEATURE_TEXT_KEYS: + for value in values.get(key, []): + if value not in features: + features.append(value) + + capacities = [n for key in _CAPACITY_KEYS for n in map(_number, values.get(key, [])) if n] + intro = next(iter(values.get("intro", [])), "") + description = next((p.strip() for p in intro.split("\n") if p.strip()), "") + + nearby: list[str] = [] + for row in ((snapshot or {}).get("local") or {}).get("contents") or []: + if not isinstance(row, dict) or row.get("content_type") != LocalContentType.ATTRACTION.value: + continue + attraction = _text((row.get("body") or {}).get("name")) or _text(row.get("title")) + if attraction and attraction not in nearby: + nearby.append(attraction) + if len(nearby) >= MAX_NEARBY: + break + + road_address, address = place.get("road_address"), place.get("address") + profile = { + "address": _text(road_address) or _text(address), + "capacity": {"max": max(capacities)} if capacities else None, + "features": features, + "nearby": nearby, + "unverified": unverified, + } + return { + "externalId": place_id, + "name": name, + "industryId": LODGING_INDUSTRY, + "regionId": region_key(road_address, address), + "description": description, + "profile": {key: value for key, value in profile.items() if value}, + } + + +def _evidence(merchant: dict) -> str: + """이 가게의 확인된 자료를 한 덩어리로. 키워드 낱말은 여기에 들어 있어야 한다.""" + profile = merchant.get("profile") or {} + parts = [ + merchant.get("name"), merchant.get("description"), profile.get("address"), + *(profile.get("features") or []), *(profile.get("nearby") or []), + ] + return _compact(" ".join(_text(p) for p in parts if p)) + + +def _supported(keyword: str, evidence: str) -> bool: + """모든 낱말이 자료에 있고, **자료로 확인한 낱말이 하나는 있어야** 한다. + + ★ 뒤 조건이 없으면 `숙소` 처럼 업종어뿐인 단어가 "주장하는 게 없다" 는 이유로 통과한다 — + 실측(2026-09-14, 실제 발행 한 바퀴)에서 메타 10칸 중 한 칸이 `숙소` 였다. 어느 가게에나 붙는 단어라 + 이 가게를 설명하지 못한다.""" + specific = False + for word in keyword.split(): + if word in _GENERIC_WORDS: + continue + core = next((word[: -len(s)] for s in _GENERIC_SUFFIXES if word.endswith(s) and len(word) > len(s)), word) + core = _compact(core) + if len(core) < _MIN_CORE_LEN or core not in evidence: + return False + specific = True + return specific + + +def _is_question(item: dict) -> bool: + canonical = _text(item.get("canonical")) + return item.get("category") == "질문형" or "?" in canonical or canonical.endswith(("요", "까")) + + +def _usable(item, evidence: str) -> str | None: + """메타 태그에 실어도 되는 추천이면 그 표기를, 아니면 None.""" + if not isinstance(item, dict) or item.get("status") != "ok" or _is_question(item): + return None + canonical = _text(item.get("canonical")) + return canonical if canonical and _supported(canonical, evidence) else None + + +def _title_keyword(result: dict, evidence: str, locality: str) -> str | None: + """제목 업종어 자리에 넣을 대표 키워드 — 유형 레인에서 고른다. + + ★ SiteOntology 설계상 "한 페이지의 주력 키워드는 1개, 유형 레인 1위가 메인 페이지 주력" 이다. + 다만 1위가 `군산 펜션 독채`(시설) 이고 2위가 `군산 독채펜션`(코어) 인 식으로 오므로 코어를 앞에 둔다. + ★ 시·군 이름을 품어야 한다. `독채펜션` 만 남으면 지금 제목(`… 군산시 숙소`)보다 지역 신호가 약해진다.""" + lane = next( + (l for l in result.get("byLane") or [] if isinstance(l, dict) and l.get("key") == "type"), + {}, + ) + items = [i for i in lane.get("items") or [] if isinstance(i, dict)] + for item in sorted(items, key=lambda i: i.get("category") != "코어"): + canonical = _usable(item, evidence) + if not canonical or _TITLE_BLOCKED_WORDS.intersection(canonical.split()): + continue + if locality and locality not in canonical: + continue + return canonical + return None + + +def select(result: dict, merchant: dict) -> dict: + """/v1/match 응답 → {keywords, titleKeyword?}. 순위는 SiteOntology 의 융합 순위를 그대로 따른다.""" + evidence = _evidence(merchant) + keywords: list[str] = [] + seen: set[str] = set() + for item in result.get("matches") or []: + canonical = _usable(item, evidence) + if not canonical or _compact(canonical) in seen: + continue + seen.add(_compact(canonical)) + keywords.append(canonical) + if len(keywords) >= MAX_KEYWORDS: + break + + seo: dict = {"keywords": keywords} + title = _title_keyword(result, evidence, _locality_word((merchant.get("profile") or {}).get("address"))) + if title: + seo["titleKeyword"] = title + return seo + + +async def fetch(place_id: str, snapshot: dict) -> dict | None: + """스냅샷에 실을 seo 값. 못 만들면 None — 예외를 올리지 않는다(키워드는 발행을 막지 않는다).""" + if not site_ontology.is_configured(): + return None + merchant = build_merchant(place_id, snapshot) + if merchant is None: + return None + try: + result = await site_ontology.match_for_merchant(merchant) + except site_ontology.SiteOntologyError as ex: + LOG.w(f"[seo] place={place_id} SiteOntology 실패 — 키워드 없이 발행: {ex}") + return None + + seo = select(result, merchant) + LOG.i( + f"[seo] place={place_id} 추천 {len(result.get('matches') or [])}건 → 메타 {len(seo['keywords'])}건" + f" · 제목 {seo.get('titleKeyword') or '(기존 제목)'}" + ) + if not seo["keywords"] and "titleKeyword" not in seo: + return None + return seo diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index e5a7932..e16f2d7 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -975,9 +975,26 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict: ], "narrative": narrative, "theme": theme, + # ★ 검색 키워드(SiteOntology). 스냅샷에 있을 때만 싣는다 — 옛 스냅샷·SiteOntology 가 꺼진 빌드에는 없고, + # 그때 렌더러는 제목·메타를 예전 그대로 굽는다(solution/site/src/seo/meta.ts). + **_seo_entry(snapshot.get("seo")), } +def _seo_entry(value) -> dict: + """스냅샷의 seo(services/seo_keywords) → payload 의 `seo`. 없으면 키 자체를 만들지 않는다. + + ★ 여기서 다시 거르지 않는다 — 이 가게 자료로 거르는 곳은 seo_keywords 한 곳이다. 모양만 확인한다. + ★ 빈 seo 를 만들지 않는다. 빈 배열은 '받았는데 비었다' 로 읽힌다(itineraries 와 같은 규칙).""" + if not isinstance(value, dict): + return {} + keywords = [_text(k) for k in value.get("keywords") or [] if _text(k)] + title = _text(value.get("titleKeyword")) + if not keywords and not title: + return {} + return {"seo": {"keywords": keywords, **({"titleKeyword": title} if title else {})}} + + def _as_float(value): try: return float(value) if value not in (None, "") else None diff --git a/solution/backend/tests/test_seo_keywords.py b/solution/backend/tests/test_seo_keywords.py new file mode 100644 index 0000000..af53bf2 --- /dev/null +++ b/solution/backend/tests/test_seo_keywords.py @@ -0,0 +1,321 @@ +"""발행 사이트 메타 태그 키워드(SiteOntology). + +이 경로가 절대 하면 안 되는 것: + - 이 가게 자료에 없는 낱말이 든 키워드를 메타 태그·제목에 싣는 것 + (확인 안 된 시설 · 다른 권역 · 가격 주장) + - SiteOntology 실패로 발행을 막는 것 — 키워드는 곁들이다 + - 해석되지 않은 업체의 추천(입력 문자열로 검색한 결과)을 쓰는 것 + - 숙박이 아닌 업종에 펜션 키워드를 붙이는 것 + +★ MATCH 의 matches 1~10 · byLane 은 실제 응답에서 옮겼다 — 2026-09-14 로컬 SiteOntology(:3100) 에 + 스테이머뭄 프로필(features 독채·프라이빗, audiences 커플·가족, nearby 말랭이마을·동국사)을 보낸 결과. + 11~13 은 같은 사전에 실제로 있는 단어로, 거르기 규칙을 보려고 붙였다. +""" +import json +import uuid + +import httpx +import pytest +from sqlalchemy import text + +from common.enums import JobStatus, LocalContentType, MediaStatus, PlaceCategory, SiteStatus, SourceType +from crud.job_crud import JobQueue +from services import seo_keywords +from services.external import site_ontology +from worker.handlers import build_handler +from worker.runner import Worker + + +def _kw(canonical, category, intent="local", status="ok"): + return {"canonical": canonical, "category": category, "intent": intent, "kind": "keyword", "status": status} + + +MATCH = { + "mode": "fusion", + "matches": [ + _kw("군산 펜션 독채", "시설"), + _kw("군산 독채펜션", "코어"), + _kw("군산 독채 마당 펜션", "롱테일"), + _kw("군산 독채 복층 펜션", "롱테일"), + _kw("군산 커플 프라이빗 펜션", "롱테일"), + _kw("군산 독채펜션 예약", "코어", intent="transactional"), + _kw("군산에 독채 펜션 있나요", "질문형", intent="informational"), + _kw("군산 가족 독채펜션", "동반자"), + _kw("말랭이마을 근처 숙소", "관광지"), + _kw("군산 프라이빗한 독채", "분위기"), + _kw("선유도 독채펜션", "권역"), + _kw("군산 펜션 최저가", "의도", intent="transactional"), + _kw("군산 주차 펜션", "시설", status="hold"), + ], + "byLane": [ + {"key": "type", "label": "유형", "items": [ + _kw("군산 펜션 독채", "시설"), + _kw("군산 독채펜션", "코어"), + _kw("군산 독채 마당 펜션", "롱테일"), + ]}, + {"key": "place", "label": "위치", "items": [_kw("말랭이마을 근처 숙소", "관광지")]}, + ], +} + + +def _fact(key, value, unit_id=None): + return {"key": key, "value": value, "scope": "unit" if unit_id else "place", "unit_id": unit_id, "status": 3} + + +def _snapshot(**place): + return { + "place": { + "name": "스테이,머뭄", "category": PlaceCategory.LODGING.value, + "road_address": "전북특별자치도 군산시 절골길 18", "address": None, **place, + }, + "facts": [ + _fact("intro", "군산 원도심 말랭이마을 옆 독채 스테이.\n두 번째 문단은 싣지 않는다."), + _fact("bbq_available", "false"), + _fact("parking", "true"), + _fact("room_type", "독채", unit_id="u1"), + _fact("max_capacity", "4", unit_id="u1"), + _fact("max_capacity", "2", unit_id="u2"), + ], + "local": {"contents": [ + {"content_type": LocalContentType.ATTRACTION.value, "title": "말랭이마을", "body": {"name": "말랭이마을"}}, + {"content_type": LocalContentType.RESTAURANT.value, "title": "이성당", "body": {"name": "이성당"}}, + {"content_type": LocalContentType.ATTRACTION.value, "title": "동국사", "body": {}}, + ]}, + } + + +# ── 요청 만들기 ───────────────────────────────────────────────────────────── +def test_스냅샷으로_업체_프로필을_만든다(): + """검증: 확인된 fact·지역 정보가 든 숙박 스냅샷. + 기대결과: 있음(true)은 features, 모름(fact 없음)은 unverified, 없음(false)은 어디에도 없다.""" + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + + assert merchant == { + "externalId": "place-1", + "name": "스테이,머뭄", + "industryId": "stay.pension", + "regionId": "kr.jeonbuk.gunsan", + "description": "군산 원도심 말랭이마을 옆 독채 스테이.", + "profile": { + "address": "전북특별자치도 군산시 절골길 18", + "capacity": {"max": 4}, + "features": ["주차", "독채"], + # 음식점(이성당)은 주변 관광지가 아니다. + "nearby": ["말랭이마을", "동국사"], + # ★ 바베큐는 false(없음)라 여기 없다 — 넣으면 SiteOntology 가 배제 대신 보류한다. + "unverified": ["애견동반", "조식"], + }, + } + + +def test_숙박이_아니면_부르지_않는다(): + assert seo_keywords.build_merchant("place-1", _snapshot(category=PlaceCategory.CAFE.value)) is None + + +def test_표에_없는_지역은_지역을_비운다(): + """검증: SiteOntology 지역 표(54개)에 없는 시·군. + 기대결과: 지어내지 않고 None — 없는 키를 보내면 SiteOntology 가 500 이다.""" + merchant = seo_keywords.build_merchant("place-1", _snapshot(road_address="서울특별시 강남구 테헤란로 1")) + assert merchant["regionId"] is None + + +def test_같은_이름의_시군은_시도로_가른다(): + assert seo_keywords.region_key("강원특별자치도 고성군 토성면 1") == "kr.gangwon.goseong" + assert seo_keywords.region_key("경상남도 고성군 고성읍 1") is None + + +# ── 거르기 ───────────────────────────────────────────────────────────────── +def test_자료가_뒷받침하는_키워드만_남긴다(): + """검증: 실제 추천 결과를 이 가게 자료(주차·독채·원도심·말랭이마을·동국사)로 거른다. + 기대결과: 마당·복층·커플·가족·프라이빗(확인 안 됨), 선유도(다른 권역), 최저가(가격 주장), + 질문형, 보류(hold)가 모두 빠진다. 순위는 SiteOntology 순서 그대로.""" + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + seo = seo_keywords.select(MATCH, merchant) + + assert seo["keywords"] == ["군산 펜션 독채", "군산 독채펜션", "군산 독채펜션 예약", "말랭이마을 근처 숙소"] + + +def test_제목_키워드는_유형_레인의_코어를_고른다(): + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + assert seo_keywords.select(MATCH, merchant)["titleKeyword"] == "군산 독채펜션" + + +def test_제목에는_예약_추천과_지역_없는_단어를_쓰지_않는다(): + """검증: 유형 레인 코어가 `… 예약` 이고, 지역명이 없는 태그가 섞였다. + 기대결과: 둘을 건너뛰고 `군산 펜션 독채` — 상호 옆에서 광고 문구가 되거나 지역 신호를 잃지 않는다.""" + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + result = {"matches": [], "byLane": [{"key": "type", "items": [ + _kw("군산 독채펜션 예약", "코어", intent="transactional"), + _kw("독채펜션", "태그"), + _kw("군산 펜션 독채", "시설"), + ]}]} + assert seo_keywords.select(result, merchant) == {"keywords": [], "titleKeyword": "군산 펜션 독채"} + + +def test_업종어뿐인_단어는_싣지_않는다(): + """검증: 낱말이 전부 업종어·"근처" 류인 추천(실측: 실제 발행에서 `숙소` 가 메타 한 칸을 차지했다). + 기대결과: 뺀다 — 어느 가게에나 붙는 단어라 이 가게를 설명하지 못한다. 자료 낱말이 섞이면 남긴다.""" + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + result = {"matches": [_kw("숙소", "태그"), _kw("펜션 예약", "의도"), _kw("원도심", "태그")], "byLane": []} + assert seo_keywords.select(result, merchant)["keywords"] == ["원도심"] + + +def test_표기만_다른_중복은_하나로(): + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + result = {"matches": [_kw("군산 독채펜션", "코어"), _kw("군산 독채 펜션", "태그")], "byLane": []} + assert seo_keywords.select(result, merchant)["keywords"] == ["군산 독채펜션"] + + +# ── 클라이언트 ──────────────────────────────────────────────────────────── +def _response(status, body, url): + return httpx.Response(status, json=body, request=httpx.Request("POST", url)) + + +@pytest.fixture +def ontology_url(monkeypatch): + monkeypatch.setattr(site_ontology.external_api_config, "site_ontology_url", "http://onto.test/") + + +def _fake_ontology(sent, *, reject_region=False, resolve=True): + async def fake_post(self, url, json=None, **kwargs): + sent.append((url, json)) + if url.endswith(site_ontology.PUBLISH_PATH): + if reject_region and json.get("regionId"): + # 실측: region 표에 없는 키 → 외래키 위반 → 500 + return _response(500, {"statusCode": 500, "message": "Internal server error"}, url) + return _response(201, {"merchant": {"external_id": json["externalId"]}, "generation": "skipped"}, url) + resolved = {"externalId": json["query"]} if resolve else None + return _response(201, {**MATCH, "resolved": resolved}, url) + + return fake_post + + +async def test_업체를_저장한_뒤_그_업체로_추천을_받는다(ontology_url, monkeypatch): + sent = [] + monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology(sent)) + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + + await site_ontology.match_for_merchant(merchant) + + assert [url for url, _ in sent] == ["http://onto.test/v1/merchants/publish", "http://onto.test/v1/match"] + # ★ generate:false 가 빠지면 SiteOntology 가 LLM 키워드 생성을 큐에 넣는다. + assert sent[0][1] == {**merchant, "generate": False} + assert sent[1][1] == {"query": "place-1", "limit": site_ontology.DEFAULT_LIMIT} + + +async def test_지역_키가_거절되면_지역_없이_다시_보낸다(ontology_url, monkeypatch): + sent = [] + monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology(sent, reject_region=True)) + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + + await site_ontology.match_for_merchant(merchant) + + publishes = [body for url, body in sent if url.endswith(site_ontology.PUBLISH_PATH)] + assert [b["regionId"] for b in publishes] == ["kr.jeonbuk.gunsan", None] + assert sent[-1][0].endswith(site_ontology.MATCH_PATH) + + +async def test_업체가_해석되지_않은_추천은_쓰지_않는다(ontology_url, monkeypatch): + """검증: match 가 resolved=null 로 201 을 준다(입력 문자열로 검색한 결과). + 기대결과: 실패로 본다 — 그 결과는 남의 동네 단어다(실측: `나운동 숙소`).""" + monkeypatch.setattr(httpx.AsyncClient, "post", _fake_ontology([], resolve=False)) + merchant = seo_keywords.build_merchant("place-1", _snapshot()) + + with pytest.raises(site_ontology.SiteOntologyError): + await site_ontology.match_for_merchant(merchant) + assert await seo_keywords.fetch("place-1", _snapshot()) is None + + +async def test_주소가_비면_부르지_않는다(monkeypatch): + monkeypatch.setattr(site_ontology.external_api_config, "site_ontology_url", "") + + async def boom(self, url, json=None, **kwargs): + raise AssertionError("설정이 비었는데 SiteOntology 를 불렀다") + + monkeypatch.setattr(httpx.AsyncClient, "post", boom) + assert await seo_keywords.fetch("place-1", _snapshot()) is None + + +async def test_연결이_안_되면_예외를_올리지_않는다(ontology_url, monkeypatch): + async def refused(self, url, json=None, **kwargs): + raise httpx.ConnectError("connection refused", request=httpx.Request("POST", url)) + + monkeypatch.setattr(httpx.AsyncClient, "post", refused) + assert await seo_keywords.fetch("place-1", _snapshot()) is None + + +# ── 발행 한 바퀴 ─────────────────────────────────────────────────────────── +REQUIRED = { + "check_in_time": "15:00", "check_out_time": "11:00", + "cancel_policy": "이용 7일 전 100% 환불, 3일 전 50% 환불", + "cooking_allowed": "true", "pet_allowed": "false", +} + + +async def _published_place(client, h, db_engine): + pid = (await client.post("/v1/place", headers=h, json={"name": "하조대독채", "category": 1})).json()["place"]["place_id"] + await client.post(f"/v1/place/{pid}/verify", headers=h, json={ + "source": 2, "road_address": f"강원특별자치도 양양군 현북면 하조대3길 {uuid.uuid4().hex[:3]}", + "phone": "033-672-0000", "latitude": "38.0219217", "longitude": "128.7221449", + }) + for k, v in {**REQUIRED, "intro": "하조대 해변 도보 3분 거리의 독채 펜션입니다.", "facilities": "독채"}.items(): + await client.post(f"/v1/place/{pid}/fact", headers=h, json={"key": k, "value": v}) + async with db_engine.begin() as c: + await c.execute( + text("INSERT INTO place_photos (media_id, place_id, url, origin_url, source_type, status, alt_text, label, sort_order) " + "VALUES (:m,:p,:u,:u,:s,:st,:a,:l,0)"), + {"m": uuid.uuid4(), "p": uuid.UUID(pid), "u": f"https://cdn.test/{uuid.uuid4().hex[:6]}.jpg", + "s": SourceType.CRAWL.value, "st": MediaStatus.APPROVED.value, "a": "2층 목조 건물 외관", "l": "외관"}, + ) + return pid + + +async def _build(client, h, pid) -> dict: + job_id = (await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True})).json()["job_id"] + assert await Worker("w", JobQueue(), build_handler(), job_deadline_sec=60).process_one() is True + job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"] + assert job["status"] == JobStatus.DONE.value, job.get("last_error") + return job["result"] + + +async def test_발행_payload_에_키워드가_실린다(auth_headers, client, db_engine, ontology_url, monkeypatch): + """검증: SiteOntology 가 켜진 채로 숙박 사업장을 발행한다. + 기대결과: 스냅샷으로 만든 프로필이 나가고, 거른 키워드가 payload.seo 로 실린다.""" + h = await auth_headers("u1") + pid = await _published_place(client, h, db_engine) + sent = [] + + async def fake_match(merchant, limit=site_ontology.DEFAULT_LIMIT): + sent.append(merchant) + return {"resolved": {"externalId": merchant["externalId"]}, + "matches": [_kw("양양 독채펜션", "코어"), _kw("양양 독채 마당 펜션", "롱테일")], + "byLane": [{"key": "type", "items": [_kw("양양 독채펜션", "코어")]}]} + + monkeypatch.setattr(site_ontology, "match_for_merchant", fake_match) + r = await _build(client, h, pid) + + assert r["published"] is True + assert sent[0]["externalId"] == pid + assert sent[0]["regionId"] == "kr.gangwon.yangyang" + assert "독채" in sent[0]["profile"]["features"] + payload = json.loads(open(r["payload_path"], encoding="utf-8").read()) + assert payload["seo"] == {"keywords": ["양양 독채펜션"], "titleKeyword": "양양 독채펜션"} + assert r["seo"] == payload["seo"] + + +async def test_SiteOntology_가_죽어도_발행된다(auth_headers, client, db_engine, ontology_url, monkeypatch): + h = await auth_headers("u1") + pid = await _published_place(client, h, db_engine) + + async def down(merchant, limit=site_ontology.DEFAULT_LIMIT): + raise site_ontology.SiteOntologyError("ConnectError: connection refused") + + monkeypatch.setattr(site_ontology, "match_for_merchant", down) + r = await _build(client, h, pid) + + assert r["published"] is True + assert "seo" not in r + site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json() + assert site["site"]["status"] == SiteStatus.PUBLISHED.value + payload = json.loads(open(r["payload_path"], encoding="utf-8").read()) + assert "seo" not in payload diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index 05bb1ff..d893580 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -55,6 +55,21 @@ export interface SitePayload { /** 템플릿 — 색/서체/섹션 순서. 관리자 에디터가 정한 값이 그대로 온다. */ theme: SiteTheme; + + /** + * 검색 키워드 — SiteOntology 가 고르고 백엔드가 **이 가게 자료로 거른** 것(services/seo_keywords). + * + * ★ 선택 필드다. 옛 payload·목업·SiteOntology 가 꺼진 빌드에는 없고, 그때 제목·메타는 예전 그대로 나간다. + * 필드를 더했을 뿐 기존 모양은 그대로라 schemaVersion 은 올리지 않는다. + */ + seo?: SiteSeo; +} + +export interface SiteSeo { + /** `<meta name="keywords">` 로 나간다. SiteOntology 융합 순위 그대로, 최대 10개. */ + keywords: string[]; + /** 제목의 업종어 자리(`<상호> · <이 값>`). 시·군 이름을 품은 유형 키워드. 없으면 예전 제목. */ + titleKeyword?: string; } export interface SiteMeta { diff --git a/solution/site/src/seo/head.ts b/solution/site/src/seo/head.ts index ef0afa0..619fffa 100644 --- a/solution/site/src/seo/head.ts +++ b/solution/site/src/seo/head.ts @@ -145,6 +145,13 @@ export function renderHead({payload, meta, scriptSrc, cssHrefs = []}: HeadOption ' <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />', ` <title>${escapeHtml(meta.title)}`, tag('meta', {name: 'description', content: meta.description}), + /* + * SiteOntology 키워드. 구글은 이 태그를 순위에 쓰지 않는다 — 비용이 없어 싣는 자리이고, + * 순위에 닿는 자리는 위의 title 이다(meta.ts homeTitle). + * ★ 조건부로 넣는다. tag() 는 빈 **속성**만 빼고 태그는 만든다 — 그대로 두면 키워드가 없는 + * 모든 사이트에 `` 빈 태그가 박힌다(meta.test.ts 가 잡았다). + */ + ...(meta.keywords ? [tag('meta', {name: 'keywords', content: meta.keywords})] : []), tag('link', {rel: 'canonical', href: meta.canonical}), // 색인을 막을 이유가 없다 — 이 사이트는 색인되라고 존재한다. tag('meta', { diff --git a/solution/site/src/seo/meta.test.ts b/solution/site/src/seo/meta.test.ts new file mode 100644 index 0000000..0f8b697 --- /dev/null +++ b/solution/site/src/seo/meta.test.ts @@ -0,0 +1,50 @@ +/** + * SiteOntology 키워드 → 제목 · 메타 키워드. + * + * ★ 백엔드(services/seo_keywords)가 이미 이 가게 자료로 거른 값만 payload.seo 로 온다. + * 여기서 지키는 것은 **자리**다 — 키워드가 없거나 제목이 너무 짧아지면 예전 제목으로 떨어져야 하고, + * 키워드가 없는 옛 payload(목업 포함)는 head 가 한 글자도 바뀌면 안 된다. + */ +import {describe, expect, it} from 'vitest'; + +import type {SitePayload} from '@o2o/shared'; + +import {MOONLIGHT_STAY_PAYLOAD} from '../fixtures/moonlight-stay'; +import {renderHead} from './head'; +import {homeMeta} from './meta'; + +function withSeo(seo: SitePayload['seo']): SitePayload { + return {...MOONLIGHT_STAY_PAYLOAD, seo}; +} + +describe('homeMeta — 제목', () => { + it('대표 키워드가 있으면 업종어 자리를 대신한다', () => { + const meta = homeMeta(withSeo({keywords: [], titleKeyword: '애월 독채펜션'})); + expect(meta.title).toBe('달빛스테이 제주 · 애월 독채펜션'); + }); + + it('없으면 예전 제목 그대로다', () => { + expect(homeMeta(withSeo({keywords: ['애월 독채펜션']})).title).toBe(homeMeta(MOONLIGHT_STAY_PAYLOAD).title); + expect(homeMeta(MOONLIGHT_STAY_PAYLOAD).title).toContain('숙소'); + }); + + it('15자보다 짧아지면 예전 제목으로 떨어진다 — Bing 이 Title too short 로 잡는다', () => { + const meta = homeMeta(withSeo({keywords: [], titleKeyword: '독채'})); + expect(meta.title).toBe(homeMeta(MOONLIGHT_STAY_PAYLOAD).title); + }); +}); + +describe('renderHead — keywords 메타', () => { + it('키워드를 쉼표로 이어 싣고 이스케이프한다', () => { + const payload = withSeo({keywords: ['애월 독채펜션', '고내리 "바다" 숙소']}); + const head = renderHead({payload, meta: homeMeta(payload)}); + expect(head).toContain(''); + }); + + it('키워드가 없으면 태그를 만들지 않는다', () => { + const head = renderHead({payload: MOONLIGHT_STAY_PAYLOAD, meta: homeMeta(MOONLIGHT_STAY_PAYLOAD)}); + expect(head).not.toContain('name="keywords"'); + const empty = withSeo({keywords: []}); + expect(renderHead({payload: empty, meta: homeMeta(empty)})).not.toContain('name="keywords"'); + }); +}); diff --git a/solution/site/src/seo/meta.ts b/solution/site/src/seo/meta.ts index f70e01f..ed5fca4 100644 --- a/solution/site/src/seo/meta.ts +++ b/solution/site/src/seo/meta.ts @@ -19,6 +19,8 @@ export interface PageMeta { canonical: string; ogImage?: string; ogImageAlt?: string; + /** ``. SiteOntology 키워드가 없으면 비어 태그를 만들지 않는다. */ + keywords?: string; } function url(payload: SitePayload, ...parts: string[]): string { @@ -128,15 +130,28 @@ const CATEGORY_WORD: Record = { [PlaceCategory.CLINIC]: '의원', }; +/** Bing 웹마스터도구가 이보다 짧은 제목을 Error(Title too short)로 잡는다. */ +const MIN_TITLE_LENGTH = 15; + /** * `<상호> · <지역> <업종>`. * * ★ `<상호> · <시군구>` 였다. "스테이,머뭄 · 군산시" 는 12자인데 Bing 웹마스터도구가 * **15자 미만을 Error(Title too short)** 로 잡는다. 광역시도와 업종어를 같이 실어 * 길이도 채우고 "군산 숙소" 같은 질의에도 걸리게 한다. + * ★ SiteOntology 대표 키워드가 오면 `<상호> · <키워드>` 다 (2026-09-14). + * "숙소" 는 업종별 고정어라 "군산 숙소" 질의에만 걸린다. 키워드는 백엔드가 이 가게 자료로 거르고 + * 시·군 이름을 품은 것만 보내므로(services/seo_keywords) 지역 신호는 그대로 남는다. + * 붙였더니 15자보다 짧으면 예전 제목으로 떨어진다. */ function homeTitle(payload: SitePayload): string { const {place} = payload; + const keyword = payload.seo?.titleKeyword?.trim(); + if (keyword) { + const title = `${place.name} · ${keyword}`; + if (title.length >= MIN_TITLE_LENGTH) return title; + } + const region = place.addressRegion && place.addressRegion !== localName(payload) ? place.addressRegion : undefined; @@ -144,6 +159,12 @@ function homeTitle(payload: SitePayload): string { return [place.name, where].filter(Boolean).join(' · '); } +/** `` 값. 키워드가 없으면 undefined — head 가 태그를 만들지 않는다. */ +export function metaKeywords(payload: SitePayload): string | undefined { + const words = (payload.seo?.keywords ?? []).map((word) => word.trim()).filter(Boolean); + return words.length > 0 ? words.join(', ') : undefined; +} + export function homeMeta(payload: SitePayload): PageMeta { const image = primaryImage(payload); return { @@ -152,5 +173,6 @@ export function homeMeta(payload: SitePayload): PageMeta { canonical: url(payload), ogImage: image?.url, ogImageAlt: image?.alt, + keywords: metaKeywords(payload), }; } From 0500ab3a3fa0c5b0864906092f5afa1cf40dd06f Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 14 Sep 2026 17:01:27 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[feat]=20site/mockup:=20/s/stay=20=EC=97=90?= =?UTF-8?q?=20=EC=97=BD=EC=84=9C=20=EC=93=B0=EA=B8=B0=20=C2=B7=20=EC=83=88?= =?UTF-8?q?=20=EA=B5=B0=EC=82=B0=20=EC=9D=BD=EA=B8=B0=EB=A5=BC=20=EB=84=A3?= =?UTF-8?q?=EB=8A=94=EB=8B=A4=20=E2=80=94=20=ED=83=AD=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '오늘의 엽서'는 미리 써 둔 문구만 보여줬다 — 손님이 사진 고르고 직접 한마디를 써서 카드를 만들고 저장·공유까지 하게 바꿨다. 군산 읽기는 xlsx 원본 대비 2줄짜리 항목이 많아 부실했고(gunsan_365_story_db.xlsx 365행은 52개 주제를 10가지 각도로 복제한 것 — 그대로 실으면 같은 말이 반복된다), 3~5줄로 다시 쓰고 확인필요 배지 대신 네이버 검색 링크로 바꿨다. 그 과정에서 군산 읽기 탭에서 다른 탭(시간의 골목)을 누르면 화면이 사라지는 버그를 잡았다 — React 가 no-op setState 를 건너뛰면서 직접 건드린 DOM 속성(hidden·aria-selected)이 안 돌아오던 것. - inject.js: 엽서 쓰기 섹션(사진 선택 → 캔버스 카드 → 저장/공유), 4줄 캡+글자 단위 줄바꿈, 공유는 이미지+링크만(캡션 중복 안 보냄) · 군산 읽기 탭 5~6개 무작위 노출 · 탭 전환 시 React 미관리 속성(style.display)으로 전환하고 클릭된 탭에 상태를 직접 강제해 no-op setState 버그 제거 - inject.css: 엽서 쓰기 버튼·썸네일 카로셀 스타일, 군산 읽기 확인필요 배지 제거, 768~1023px 구간에서 하단 탭바가 푸터 마지막 줄을 덮는 것 막는 보정 (SiteFooter.tsx 는 md: 768px 부터, MobileTabBar.tsx 는 lg: 1024px 부터 기준을 잡아 그 사이가 비었다 — 렌더러 쪽 근본 수정은 별도) - build_reading.py: 34꼭지 재작성(2줄짜리 21개를 3~5줄로), 출처를 네이버 검색 링크로 통일, verified 필드 제거 - build_daily.py · dump_text.py: 위에서 뺀 verified 필드를 읽던 자리 방어 처리 - README.md: 1부(T7 군산 읽기 · T8 엽서 쓰기)를 실제 구현 가이드로 다시 씀 — 히스토리·인용 없이 무엇을 만들어야 하는지만, 엽서는 AI/스키마 작업이 아니라 UI 그대로 옮기면 되는 것임을 명시 검증: Playwright 로 탭 재전환·엽서 줄바꿈 재현 확인. 킹서버 반영 후 `docker exec o2o-web4ai-solution-site md5sum` 로컬-킹 일치 확인. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XYUb8ZfNiDd15tZRYrusCX --- solution/site/scripts/mockup/README.md | 328 +++- solution/site/scripts/mockup/TEXT.md | 194 ++- solution/site/scripts/mockup/build_daily.py | 152 ++ solution/site/scripts/mockup/build_reading.py | 301 ++++ solution/site/scripts/mockup/dump_text.py | 47 +- solution/site/scripts/mockup/inject.css | 295 ++++ solution/site/scripts/mockup/inject.js | 1334 ++++++++++++++++- .../site/scripts/mockup/stay-payload-new.json | 2 +- 8 files changed, 2563 insertions(+), 90 deletions(-) create mode 100644 solution/site/scripts/mockup/build_daily.py create mode 100644 solution/site/scripts/mockup/build_reading.py diff --git a/solution/site/scripts/mockup/README.md b/solution/site/scripts/mockup/README.md index b1d7784..87a003c 100644 --- a/solution/site/scripts/mockup/README.md +++ b/solution/site/scripts/mockup/README.md @@ -59,7 +59,7 @@ | 파일 | 옮길 자리 | |---|---| | 이 문서 **1.4 프롬프트 전문** | `shared/src/lib/section-prompts.ts` | -| `build_itinerary.py` (규칙 16종) | `backend/services/itinerary.py` | +| `build_itinerary.py` (규칙 17종) | `backend/services/itinerary.py` | | `build_story.py` (위키 사진 수집) | `backend/services/external/wikimedia.py` | | `inject.js` (렌더러에 **없는** 기능 3개) | `site/src/sections/` | @@ -81,25 +81,26 @@ | `photos` | 사진 갤러리 | `media[]` | 사장님이 올림 | | | `map` | 오시는 길 | `place` + `routes[]` | 수집 | | | `festival` | 계절별 축제 | `local.festivals` | TourAPI 수집 | | -| `local` | 지역 정보 | `local.attractions/restaurants` | TourAPI 수집 | | +| `local` | 지역 정보 | `local.attractions/restaurants` | TourAPI 수집 (시연본 맛집은 `restaurants.json` 31곳) | | | `itinerary` | **추천 일정** | `data(kind=itinerary)` | `services/itinerary.py` + 빌더 프롬프트 `dataSpec.ts:556` | **T2** | | `story` | 군산 이야기 | 아래 6탭 껍데기 | — | | | `songs` | 가요 다방 | `data(kind=songs)` | LLM `section-prompts.ts:67` | **T3 T5** | -| `daily` | 오늘의 한 장 | `data(kind=daily)` | LLM `:91` | 섹션 자리가 없다 | +| `daily` | 오늘의 한 장 | `data(kind=daily)` | LLM `:91` | 시연본은 노래·문학 39장(`build_daily.py`) | | `people` | 인물 열전 | `data(kind=people)` | LLM `:113` | **T3 T5** | | `chronicle` | 시간의 골목 | `data(kind=chronicle)` | LLM `:135` | | -| `postcard` | 오늘의 엽서 | `data(kind=postcard)` | LLM `:160` | | +| `postcard` | 오늘의 엽서 | `data(kind=postcard)` | LLM `:160` | 시연본은 탭을 비우고 독립 "엽서 쓰기" 로 대체(**2.5 항목 9**) | | `quiz` | 뒤집어 보는 질문 | `data(kind=quiz)` | LLM `:188` | | | `faq` | 자주 묻는 질문 | `faqs[]` | LLM + 사장님 | | | `rules` | 이용 규정 | `facts[]` | 사장님이 적음 | | | `weather` | 날씨 | `local.weather` | open-meteo | | | `planner` | 계절별 추천 하루 | `data(kind=planner)` | **없음** | (시연본은 꺼 둠) | -섹션이 아닌 것 하나가 더 있다 — 헤더 음악 플레이어(**T4**). +섹션이 아닌 것이 셋 더 있다 — 헤더 음악 플레이어(**T4**) · **군산 읽기**(**T7**, '군산 이야기' +탭 묶음의 여섯 번째 탭) · **엽서 쓰기**(독립 섹션, `festival` 바로 앞). --- -## 1.3 할 일 여섯 +## 1.3 할 일 여덟 ### T1 — 히어로 캐치프레이즈 @@ -118,7 +119,10 @@ weather?: '맑음'|'구름많음'|'흐림'|'비'|'눈'; }[] }; ``` -2. 히어로가 **지금 조건에 맞는 것만 후보**로 두고 돌린다. +2. **대표 문구(`tagline`)는 고정하고, 순환은 그 아래 줄에서 돈다** (2026-09-11 사장님 지시). + 대표 문구 자리를 갈아 끼우면 숙소의 한 줄이 첫 화면에서 사라진다. 아래 줄은 대표 문구보다 + 한 단 작고 옅게, 높이는 가장 긴 문구에 맞춰 잡는다(히어로가 아래 정렬이라 줄 수가 바뀌면 튄다). + 히어로가 **지금 조건에 맞는 것만 후보**로 두고 돌린다. 순환 순서는 `일반 → 계절 → 일반 → 월 → 일반 → 날씨` 한 바퀴를 만들고 그 바퀴를 돈다. 일반은 자루에서 뽑아 쓰고 비면 다시 채운다(같은 문장이 연달아 안 나오게). 계절은 3개월 단위(3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울). @@ -161,7 +165,7 @@ |---|---|---|---| | **① 장소 대장** | LLM | 장소 30곳 × `{stayMinutes:{min,base,max}, todo, indoor, needsCar, bestTime, searchQuery}` | **1.4 ②** | | **② 좌표** | 코드 | 카카오 로컬로 `searchQuery` → 위경도. **LLM 이 적은 좌표는 쓰지 않는다** | — | -| **③ 조립** | **코드** | 이동시간·도착시각·끼니 창 맞추기·규칙 16종 감사 | 없음 (`build_itinerary.py`) | +| **③ 조립** | **코드** | 이동시간·도착시각·끼니 창 맞추기·규칙 17종 감사 | 없음 (`build_itinerary.py`) | | **④ 테마** | LLM | 테마 20개 × `{name, duration, audience, why, days[{kind, places[]}]}` | **1.4 ③** | **코드가 하는 일(③)에서 반드시 옮겨야 하는 것** — 근거는 `build_itinerary.py` 줄번호 @@ -174,7 +178,7 @@ | 창 맞추기 | 어긋나면 **앞 칸 체류를 범위(min~max) 안에서** 늘이고 줄인다. 이동시간을 고치지 않는다. 그래도 안 되면 시작 시각을 10분씩 옮기고, 그래도 안 되면 **그 조합을 만들지 않는다** | `plan_day()` | | 테마별 완급 | 시작 시각·체류 배율이 테마마다 다르다(쉬러 오면 1.25배, 사진 찍으면 0.8배) → 끝나는 시각이 13:35~21:00 로 퍼진다 | `:470` | | 끼니 간격 | 앞 끼니가 끝나고 **3시간**이 지나야 다음 끼니 창이 열린다(`MEAL_GAP`). 창만 지키면 통과하던 자리다 | `_window_of()` | -| **감사 16종** | 하나라도 어기면 **payload 를 쓰지 않고 멈춘다** | `:526` · 목록은 **2.4** | +| **감사 17종** | 하나라도 어기면 **payload 를 쓰지 않고 멈춘다** | `:575` · 목록은 **2.4** | ★ **감사는 생성기 안에 둔다.** 밖에 두면 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생긴다 — 실제로 그래서 33건이 통과했다(**3.2**). @@ -182,12 +186,18 @@ **하루 뼈대** — `@` 는 숙소 ``` -첫날 체크인@ · 오후 · 오후 · 저녁 · 밤산책 · 복귀@ -가운데날 아침@ · 오전 · 점심 · 오후 · 오후 · 쉼@ · 저녁 · 복귀@ -쉬는날 숙소에서 쉬는 날@ · 점심 · 쉼@ · 저녁 · 복귀@ -마지막날 아침(퇴실)@ · 오전 · 점심 · 오후 · 마무리@ +첫날 체크인@ · 오후 · 오후 · 저녁 · 밤산책 · 복귀@ +가운데날 아침@ · 오전 · 점심 · 오후 · 오후 · 쉼@ · 저녁 · 복귀@ +쉬는날 숙소에서 쉬는 날@ · 점심 · 쉼@ · 저녁 · 복귀@ +마지막날(도보) 아침(퇴실)@ · 오전 · 점심 · 오후 · 마무리@ ← 짐을 맡기고 저녁에 찾으러 온다 +마지막날(차) 아침(퇴실·차)@ · 오전 · 점심 · 오후 ← 짐을 싣고 나섰다, 복귀 칸이 없다 ``` +★ **떠나는 날은 둘로 갈린다** (2026-09-14 대표 의견). 차로 온 손님에게 "짐을 맡기고 저녁에 + 찾으러 오세요" 는 **한 번 더 들르라는 말**이다 — 퇴실할 때 실으면 끝날 일이다. 그래서 차 테마의 + 마지막 날은 마지막 정거장에서 끝난다. 짐 보관 동선은 걸어 다니는 손님 것이고 그 테마만 남는다. + 뼈대를 두 벌로 적지 않고 **복귀 칸만 떼어** 만든다(`build_itinerary.py` `CAR_KINDS`). + **완료 기준** 새 업장에서 테마 20개가 나오고 `audit()` 위반 0건. 하루가 숙소로 시작해 숙소로 끝나고, 끼니가 제 시각에 있고, 21시를 안 넘는다. @@ -220,9 +230,18 @@ **할 일** `MediaItem` 에 `kind:'audio'`(+`title` `artist` `durationSec`)를 넣고 발행 때 같이 올린다. 헤더에 붙는 작은 플레이어를 만든다 — 아이콘 · 현재 곡명 · 재생/멈춤 · 목록. -**자동재생은 넣지 않는다**(2026-09-11 지시). 순서는 **랜덤 시작 후 순차**. +순서는 **랜덤 시작 후 순차**. 목업 구현은 `inject.js` 의 `startPlayer()`, 모양은 `inject.css` 의 `#w4d-mini` · `#w4d-panel`. +★ **자동재생한다**(2026-09-14 대표 지시 — 2026-09-11 의 "자동 빼" 를 뒤집은 것이다). + 다만 **브라우저가 막는다.** 크롬·사파리는 손님이 페이지를 한 번도 만지지 않았으면 소리 나는 + 재생을 거절한다(정책이고, 거절된 약속으로 온다). 실측 2026-09-14, 크롬: 불러온 직후 재생 0, + 세로 스크롤 한 번에 0:02 부터 재생. 그래서 구현이 **두 단계**다 — + ① 불러오자마자 걸어 본다 ② 막히면 손님이 **처음 만지는 순간**(스크롤·클릭·키·터치) 켠다. + → 첫 손길 리스너는 `#w4d-mini`·`#w4d-panel` 안에서 난 이벤트를 **흘려보내야 한다.** + 예전 판은 window 캡처라 재생 단추의 클릭을 먼저 먹었고, 단추 핸들러가 다시 꺼서 + 두 번 눌러야 켜졌다(2026-09-11). 검사는 `audit-all.mjs` 의 `자동재생`. + --- ### T5 — 상한(`maxItems`) @@ -254,6 +273,68 @@ --- +### T7 — 도시를 펼쳐 보이는 자리 + +**증상** 도시 이야기를 담는 자리가 전부 한 번에 하나만 보인다. 하루에 한 장만 보여주는 +일력이나, 넉 장짜리 엽서 레일 정도로는 군산이라는 도시를 소개하기엔 부족하다. + +**할 일** 문학·역사·섬과 바다·장소·음식처럼 갈래로 나눈 도시 이야기를, 한 화면에서 +옆으로 넘겨 가며 볼 수 있는 자리를 만든다. + +1. 이 콘텐츠는 **사장님마다 새로 만드는 게 아니다.** 군산이라는 지역이 공유하는 고정 + 자료다(계절별 축제·주변 명소와 같은 성격) — 사이트를 만들 때마다 AI 가 새로 글을 + 지어내는 게 아니라, 군산에 있는 모든 사이트가 같은 이야기 목록을 나눠 쓴다. +2. 항목마다 출처 링크를 단다. 정확한 문서 주소를 일일이 확인하기 어려우니, 제목으로 + 검색되는 링크(예: 네이버 검색)를 걸어 둔다 — 없는 주소를 지어내 걸지 않는다. +3. 옆으로 넘겨 보는 방식은 **이 사이트가 이미 쓰고 있는 것**을 그대로 쓴다(사진 갤러리· + 추천 일정에 쓰는 그 슬라이드). 새로 스크롤 상자를 만들지 않는다. 한 슬라이드에는 + 이야기 하나만 크게 보여준다 — 여러 개를 한 슬라이드에 욱여넣지 않는다. +4. 한 번 볼 때 전체 이야기를 다 보여주지 않는다. 그중 몇 개만 무작위로 뽑아 보여주고, + 다시 오거나 새로고침하면 다른 조합이 뜬다. 화면 문구에 "전체 몇 개"라고 숫자를 + 박지 않는다 — 실제로 보이는 개수와 어긋나면 안 되기 때문이다. +5. 화면 안에서는 '군산 이야기'(가요 다방·인물 열전 등이 모여 있는 탭 묶음)의 탭 + 하나로 들어간다 — 독립된 섹션으로 따로 빼지 않는다. 다른 탭들처럼 탭 안에도 + 제목을 그대로 보여준다. +6. 이미 다른 자리에 나온 이야기는 다시 넣지 않는다 — 인물·가요·축제·연표·명소·맛집이 + 각자 자리를 갖고 있다. 같은 이야기를 두 번 보여주면 페이지만 길어진다. + +**완료 기준** 갈래를 가리지 않고 훑어볼 수 있고, 이야기마다 출처가 붙어 있고, 넘기는 +방식이 이 사이트에서 이미 쓰는 방식과 같다. + +--- + +### T8 — 엽서 쓰기 + +**증상** '오늘의 엽서'는 미리 써 둔 문구를 보여주기만 했다. 손님이 직접 한마디를 써서 +자기만의 엽서를 만들고, 그걸 저장하거나 공유할 수 있게 바꾼다. + +**이건 AI 가 글을 짓는 기능이 아니다.** 새 데이터 형식이나 문구를 설계할 필요 없이, +이미 만들어 확인까지 끝낸 화면 기능 하나를 그대로 옮겨 붙이면 되는 일이다. + +1. 숙소 사진(가진 사진 전체를 보여주고 손님이 그중 하나를 고르게 한다) + 손님이 + 직접 쓴 한마디 + 우표·소인 모양을 합쳐서 한 장의 카드 이미지로 만든다. +2. 문구는 최대 네 줄까지만 쓸 수 있게 막는다. 줄이 넘어가면 자동으로 다음 줄로 + 넘어가는데, 띄어쓰기 없이 길게 이어 쓴 글자도 카드 밖으로 삐져나오지 않고 + 글자 단위로 잘 접혀야 한다. +3. 완성한 카드는 사진으로 저장하거나 공유할 수 있다. 공유할 때는 카드 이미지와 + 함께, 지금 보고 있는 이 사이트 페이지로 가는 링크를 같이 보낸다. 손님이 쓴 + 문구는 이미 카드 그림 안에 박혀 있으니, 공유 문구 자리에 그 글을 따로 한 번 더 + 적지 않는다 — 같은 말이 두 번 보이면 안 된다. 서버에 새로 올리는 기능을 만들 + 필요 없이 휴대폰·브라우저가 원래 갖고 있는 공유 기능을 그대로 쓰고, 그래서 이 + 기능은 서버를 한 번도 부르지 않는다. +4. 공유 버튼이나 안내 문구에 특정 메신저 이름을 못 박지 않는다 — 기기에 따라 + 실제로 뜨는 공유 대상이 다르다(휴대폰은 설치된 메신저가 뜨지만, 컴퓨터 특히 + 맥은 카카오톡이 그 목록에 안 뜬다). 버튼 문구는 손끝으로 조작하는 기기에서만 + "카카오톡 등으로 공유"라고 쓰고, 그 외에는 그냥 "공유하기"라고 쓴다. +5. 상호명을 코드 안에 고정으로 박아 넣지 않는다 — 이 기능은 다른 숙소나 카페에서도 + 그대로 가져다 써야 한다. +6. 화면 위치는 "계절별 축제" 섹션 바로 앞에 독립된 섹션으로 둔다. + +**완료 기준** 손님이 사진 고르고 글 쓰고 저장·공유하는 것까지 한 화면에서 끝난다. +새 서버 기능이나 AI 프롬프트가 필요 없다. + +--- + ## 1.4 프롬프트 전문 **프롬프트는 지금 두 파일에 나뉘어 있다. 이게 문제다.** @@ -307,7 +388,7 @@ backend/services/prompts/section_prompts.json ← 산출물. 손으로 고치 창고형 카페 공간 · 침구 매일 세탁 · 히로쓰 가옥 옆 · 초원사진관 도보 3분} [해야 할 일] -[업소] 의 히어로 문구를 100개 만든다. 상호 바로 아래에 한 줄로 뜨고 7초마다 바뀐다. +[업소] 의 히어로 문구를 100개 만든다. 상호와 대표 문구 아래에 한 줄로 뜨고 7초마다 바뀐다. 네 종류로 나눠 만든다. · 일반 40개 — 언제 나와도 되는 문구 @@ -552,9 +633,11 @@ backend/services/prompts/section_prompts.json ← 산출물. 손으로 고치 stay-payload.json 원본 payload (건드리지 않는다 · 되돌릴 때 기준) orig/index.html 원본 HTML (건드리지 않는다) │ - ├─ build_itinerary.py 여행 일정을 계산해 넣는다 (+ 규칙 16종 감사) + ├─ build_itinerary.py 여행 일정을 계산해 넣는다 (+ 규칙 17종 감사) ├─ build_story.py 가요 다방 · 인물 열전을 넣는다 - └─ patch_stay.py 캐치프레이즈 · 객실 사진 · 자작곡 · 주입 CSS/JS · 자산 경로 교정 + ├─ build_reading.py 군산 읽기(카로셀 34꼭지, 화면엔 매번 5~6개 무작위)를 넣는다 + ├─ build_daily.py 오늘의 한 장(일력 39장)을 넣는다 — 가요·인물·문학을 읽어 쓰므로 맨 뒤 + └─ patch_stay.py 캐치프레이즈 · 주변 맛집 · 객실 사진 · 자작곡 · 주입 CSS/JS · 자산 경로 교정 │ ▼ build/index.html ← 배포하는 파일 (git 이그노어) @@ -599,19 +682,27 @@ node audit-all.mjs ### 내용을 바꾼 뒤 (전체 굽기) ```bash -python3 build_itinerary.py && python3 build_story.py && python3 patch_stay.py +python3 build_itinerary.py && python3 build_story.py && python3 build_reading.py \ + && python3 build_daily.py && python3 patch_stay.py ``` **순서를 지킨다.** `build_itinerary.py` 가 `stay-payload.json`(원본)을 읽어 -`stay-payload-new.json` 을 만들고, 뒤 둘이 그 파일을 이어서 고친다. +`stay-payload-new.json` 을 만들고, 뒤 셋이 그 파일을 이어서 고친다. 순서를 바꾸면 앞 단계 결과가 지워진다. +`build_daily.py` 는 가요 다방·인물 열전·군산 읽기를 **읽어서** 일력을 만든다 — 맨 뒤여야 한다. +손으로 적는 자리가 한 곳이어야 해서다: 문학 8꼭지의 문장은 `build_reading.py` 에만 있다. + +주변 맛집을 다시 받을 때만 `solution/backend` 에서 `.venv/bin/python ../site/scripts/mockup/fetch_restaurants.py` 를 먼저 돌린다(TourAPI 키가 필요하다). +굽기는 그 결과 파일만 읽는다 — 굽기가 키에 매이면 다른 기계에서 못 굽는다. +그다음 `python3 fill_restaurant_photos.py` 로 사진 없는 집의 사진을 받는다. ⚠ 이 11장은 블로그·네이버 플레이스·관광 사이트에 올라온 것이라 **권리가 게시자에게 있다** — 시연 뒤 내리거나 사장님 사진으로 바꾼다. 제품으로 옮기지 않는다. 로컬 반영 · 검사: ```bash docker cp build/index.html o2o-web4ai-solution-prerender:/app/solution/site/out/s/stay/index.html -node audit-all.mjs # 34개 항목 -node rails-test.mjs # 카로셀 13개 +node audit-all.mjs # 47개 항목 +node rails-test.mjs # 카로셀 13개 (모바일은 +3) +python3 audit_schedule.py # 일정 전수 검수표(SCHEDULE.md) ``` `/s/` 는 **5분 캐시**(`max-age=300`)다 — 확인할 때 ⌘⇧R. @@ -645,14 +736,32 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/" | 자리 | 줄 | 무엇 | |---|---|---| -| `GENERAL` | `:20` | 일반 40개 | -| `SEASON` | `:62` | 계절별 24개 — 봄/여름/가을/겨울 각 6 | -| `MONTH` | `:72` | 월별 24개 — 1~12월 각 2 | -| `WEATHER` | `:87` | 날씨별 12개 — 맑음/구름많음/흐림/비/눈 | +| `GENERAL` | `:29` | 일반 40개 | +| `SEASON` | `:71` | 계절별 24개 — 봄/여름/가을/겨울 각 6 | +| `MONTH` | `:81` | 월별 24개 — 1~12월 각 2 | +| `WEATHER` | `:96` | 날씨별 12개 — 맑음/구름많음/흐림/비/눈 | 문장만 더하거나 지우면 된다. 개수 제한은 없다. 순환 순서·계절 구분·날씨 연동은 **1.3 T1** 에 적어 둔 그대로다(`inject.js`). +### 오늘의 날씨 문구 50개 → `patch_stay.py` + +| 자리 | 줄 | 무엇 | +|---|---|---| +| `SKY_NOTES` | `:114` | 하늘 25개 — 맑음/구름많음/그외/비/눈 각 5. **집 안**에서 뭘 할지 | +| `TEMP_NOTES` | `:152` | 기온대 25개 — 혹서/더움/선선/쌀쌀/추움 각 5. **밖에서** 어디를 갈지 | + +케이스마다 여러 벌이고 20초마다 자루에서 다음 것이 나온다(`inject.js` ⑥). +굽는 판에는 케이스마다 **첫 문장**이 박히고, 나머지는 `local.weather.noteSets` · +`tempNoteSets` 로 실려 주입분만 읽는다 — 렌더러 계약에 없는 칸이라 그대로 지나간다. + +- 개수 제한은 없다. 다만 **문장이 겹치면 빌드가 멈춘다** — 주입분이 DOM 에서 바꿀 줄을 + 문장으로 찾기 때문에, 같은 문장이 둘이면 어느 줄인지 못 가른다. +- 이름을 대는 곳은 이 사이트에 **이미 있는 자리**만 쓴다(주변 안내 12곳 · 맛집 31곳). + 화면에 없는 곳을 날씨 칸에서만 권하면 손님이 그걸 찾을 데가 없다. +- 기온대 경계(30·25·20·10)는 렌더러가 정한다(`site/src/lib/derive.ts weatherBand`). + 화면의 배지는 렌더러가 붙이고 문장만 주입분이 갈아 끼우므로, 경계를 바꾸면 둘이 어긋난다. + ### 여행 일정 → `build_itinerary.py` **사람은 "어느 자리에 어느 장소" 만 고르고, 시각은 스크립트가 계산한다.** @@ -660,17 +769,55 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/" | 자리 | 줄 | 무엇 | |---|---|---| | `PLACES` | `:40` | **장소 대장** — 좌표 · 지도 검색어 · 체류시간(min/base/max) · **거기서 무엇을 하는지** | -| `STAY_ACTION` | `:108` | 머뭄 칸의 행위 — 체크인 · 아침 · 아침(퇴실) · 쉼 · 쉬는 날 · 머뭄으로 · 마무리 | -| `WINDOW` | `:156` | 시간대가 정해진 자리 | -| `SKELETON` | `:182` | 하루 뼈대. `@` 가 붙은 것은 머뭄 | -| `THEMES` | `:368` | 테마 21개 | -| `PACING` | `:470` | 테마별 시작 시각과 체류 배율 | +| `STAY_ACTION` | `:108` | 머뭄 칸의 행위 — 체크인 · 아침 · 아침(퇴실) · **아침(퇴실·차)** · 쉼 · 쉬는 날 · 머뭄으로 · 마무리 | +| `WINDOW` | `:162` | 시간대가 정해진 자리 | +| `SKELETON` | `:194` | 하루 뼈대. `@` 가 붙은 것은 머뭄. **떠나는 날은 차·도보 두 벌**(`CAR_KINDS` 가 복귀 칸을 떼어 만든다) | +| `THEMES` | `:414` | 테마 21개. 마지막 날 뼈대가 `last_car` 계열이면 **차로 떠나는 테마**다(지금 14개 — 1박 2일 7 · 2박 3일 7) | +| `PACING` | `:516` | 테마별 시작 시각과 체류 배율 | **새 장소** → `PLACES` 에 한 줄. 좌표는 카카오 로컬에서 받은 실제 값이어야 한다(지어내지 않는다). 그다음 `THEMES` 의 원하는 자리에 이름을 적는다. **새 테마** → `THEMES` 에 한 덩이, `PACING` 에 한 줄. 뼈대의 `@` 없는 자리 수와 장소 개수가 같아야 한다 — 틀리면 빌드가 멈추고 알려 준다. +### 군산 읽기 → `build_reading.py` + +| 자리 | 무엇 | +|---|---| +| `GROUPS` | 갈래와 그 아래 한 줄. 화면에 이 순서로 선다 | +| `ITEMS` | 꼭지 34개 — (갈래, 제목, 글, 연도). **문학 문장의 단일 출처**다 | +| `naver(title)` | 항목마다 다는 출처 — 네이버 검색 링크 하나로 통일(아래 참고) | + +- **이미 다른 섹션에 선 것은 넣지 않는다.** 인물 57 · 가요 25 · 축제 10 · 연표 7 · 명소 12 · + 맛집 31 이 각자 자리를 갖는다. 받은 엑셀 52주제에서 그걸 빼면 34꼭지가 남는다. +- 연도는 **확인된 것만** 적는다(연표에서 이미 확인한 값, 엑셀이 명시한 1944 등). +- 글은 **3~5문장**이다(2026-09-14 대표: "2줄있는애들 최소 3-5줄 이였으면 좋겠오") — + 두 문장짜리 21꼭지를 네 문장으로 늘렸다. 새로 보탠 문장은 없는 사실을 지어낸 게 아니라 + 널리 알려진 배경 설명이다. +- **`verified`("확인"/"확인필요") 필드는 뺐다.** 대신 `source` 를 **네이버 검색 링크**로 + 통일했다(`naver(title)` — `https://search.naver.com/search.naver?query=...`). 기관·위키 + 링크를 항목마다 골라 달던 방식(`CHAE`·`STAMP`·`TOUR`·`WIKI`·`KCI`·`GUIDE` 상수)은 없앴다 — + 개별 문서 URL 은 실제로 확인 안 하고 적으면 없는 문서로 이어질 위험이 있어서다(레포 절대 + 규칙: 없는 사실은 안 짓는다). 검색 링크는 제목을 그대로 넘기니 항상 관련 결과로 뜬다. +- 모양은 `inject.css` `.w4d-read-*` — 이 사이트의 실제 카로셀(embla)이다. 신문 조판(제호·면·단) + 은 한 번 시도했다가 되돌렸다 — 경위는 **2.5 항목 3**. +- 화면은 **전체 중 5~6개만 매번 무작위로** 보여준다(갈래 안 가림) — payload 에는 34개가 + 전부 있고, `subtitle` 에도 전체 개수를 안 박는다(**2.5 항목 3**). + +### 오늘의 한 장(일력) → `build_daily.py` + +| 자리 | 무엇 | +|---|---| +| `POETS` | 시인 6명. 이름만 적으면 인물 열전에서 한 줄과 위키 출처를 가져온다 | +| (자동) | 가요 25장은 가요 다방을, 문학 8장은 군산 읽기를 **그대로 읽는다.** 목록을 두 벌로 두지 않는다 | + +- 이 자리는 **도시를 소개하는 자리가 아니다** — 세 장만 화면에 선다. 소개는 '군산 읽기' 가 한다. +- 날짜는 **고르게 편다**(39장 → 9일 간격). 곡이 나온 날·작가의 생일에 맞추고 싶지만 + 그 날짜를 출처로 확인한 것이 없다 — 지어낸 날짜는 해마다 틀린다. +- 오늘 자리가 비면 렌더러가 **다음 장**을 편다(`DailySection.tsx`). 365장을 채우려면 + 같은 글을 여러 날짜에 복사해야 하는데, 인용되려고 만든 사이트에서 할 일이 아니다. +- 받은 자료(`gunsan_365_story_db.xlsx`)를 그대로 싣지 않는 이유는 `build_daily.py` 머리말에 있다. + ### 가요 다방 · 인물 열전 → `build_story.py` | 자리 | 줄 | 무엇 | @@ -685,13 +832,13 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/" - ★ 이름만으로 커먼즈를 검색하지 않는다(**1.3 T3**). - 숙소가 만든 곡은 이 섹션에 넣지 않는다. 도시의 노래를 모으는 자리다. -### 객실 사진 → `patch_stay.py` `ROOM_PHOTOS`(`:142`) · `rooms.json` +### 객실 사진 → `patch_stay.py` `ROOM_PHOTOS`(`:267`) · `rooms.json` --- -## 2.4 규칙 16종 — 어기면 빌드가 멈춘다 +## 2.4 규칙 17종 — 어기면 빌드가 멈춘다 -`build_itinerary.py` 의 `audit()`(`:526`)이 매 실행 검사한다. +`build_itinerary.py` 의 `audit()` 가 매 실행 검사한다. 하나라도 어기면 **payload 파일을 쓰지 않고** 위반 목록을 찍고 멈춘다. | | 규칙 | 이 규칙이 없어서 났던 일 | @@ -712,24 +859,91 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/" | 14 | 아이 동반 테마에 술집 금지 | | | **15** | **끼니 사이 3시간 이상** | 늦은 점심 15:47 종료 → 저녁 17:30. 창은 둘 다 합법인데 배가 안 꺼진다 | | **16** | **볼거리는 편도 이동보다 오래 머문다**(끼니 제외) | 사장님 지적 — 밥집은 목적지가 아니라 돌아오는 길에 들르는 자리라 뺀다 | +| **17** | **떠나는 날의 끝맺음이 테마와 맞는다** — 차 테마는 복귀 칸이 없고, 도보 테마는 짐을 찾는 `마무리` 칸으로 끝난다 | 2026-09-14. 한쪽만 고치면 차인데 숙소로 되돌아오거나(한 번 더 들르기) 맡긴 짐을 못 찾고 끝난다 — 둘 다 화면만 봐서는 멀쩡하다 | ★ **왜 빌드 안인가** — 전에는 이 검사를 매번 따로 쓴 일회용 스크립트로 돌렸다. 그래서 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생겼고 사장님이 화면에서 먼저 찾으셨다. **검사가 빌드의 일부가 아니면 검사한 적 없는 것과 같다.** -`audit-all.mjs` 는 그다음 단계다 — 구워진 HTML 을 **실제 브라우저로 열어** 34개 항목을 센다. +`audit-all.mjs` 는 그다음 단계다 — 구워진 HTML 을 **실제 브라우저로 열어** 47개 항목을 센다. --- -## 2.5 주입분(`inject.js` · `inject.css`) 다섯 +## 2.5 주입분(`inject.js` · `inject.css`) 아홉 렌더러에 없어서 이 목업에만 얹은 것이다. 제품으로 옮길 자리는 1부에 적었다. -1. **캐치프레이즈 순환** — React 가 그린 `

` 를 찾아 단어 단위로 갈아 끼운다 → T1 -2. **헤더 미니 플레이어** — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡 → T4 -3. **링크 걸기** — 일정 정거장 187개 · 엽서 4개를 네이버 지도로 -4. **사진 저작자 표시** — 이 목업이 물고 있는 번들이 옛 판이라 `imageCredit` 을 안 그린다 → T3 -5. **카로셀 제어** — 아래 +1. **캐치프레이즈 순환** — 대표 문구 `

` 를 찾아 **그 아래에 줄(`#w4d-sub`)을 붙이고** 단어 단위로 갈아 끼운다. 대표 문구는 안 건드린다 → T1 +2. **헤더 미니 플레이어** — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡. + **자동재생**은 걸어 보고, 브라우저가 막으면 첫 손길에 켠다(**1.3 T4**) → T4 +3. **군산 읽기** — 렌더러에 없는 섹션. '군산 이야기' 탭 묶음의 **여섯 번째 탭**으로 들어가 + 34꼭지 중 5~6개를 매번 무작위로 카로셀(embla, 한 슬라이드에 한 꼭지) 로 보여준다. + `startReading()` 이 story 탭 패널들의 마지막(postcard) 뒤에 자기 패널을 꽂고, + 자기 탭 버튼은 진짜 탭들의 `[role="tablist"]` 끝에 붙인다 → **T7** + - 처음엔 "신문 조판"(제호·면·단)으로 시작했다가, 3~4개씩 갈래별로 뽑아 한 카로셀에 + 잇는 중간 단계를 거쳐, 지금은 갈래를 안 가리고 **전체에서 5~6개**로 정착했다 + (2026-09-14 하루 안에서: "신문처럼" → "포스트 하나씩만…나랑 장난하자는거?" → + "문학면? 말이 안 되는거 같네" → "34개중 15~19개인데 왜 34개라고 써놔?" → + "그냥 5-6개 정도만…랜덤으로"). + - ★ **탭 전환 버그** (2026-09-14 대표 제보: "시간의 골목 누르면 사라지기만 함, + 탭활성화도 그렇고"). 진짜 탭 다섯 개의 `hidden`·`aria-selected`·style 은 전부 + React 가 `active` state 로 매번 다시 계산해 주는 값인데, 예전 코드가 내 탭으로 + 넘어갈 때 그 값들을 **직접 덮어썼다.** 그 뒤 손님이 (내 탭에 있다가) **직전까지 + 활성이던 그 탭을 다시 누르면** `setActive(sameIndex)` 는 React 입장에서 상태가 + 안 바뀐 것이라 **리렌더를 생략한다**(bail-out) — 내가 덮은 값이 영영 안 돌아왔다. + → **React 가 매 렌더 계산해 주는 속성은 아예 안 건드린다.** 대신 내가 전적으로 + 소유한 `style.display`(JSX 에 없는 속성이라 React 가 절대 안 건드린다)로만 가리고, + 되돌아올 때는 그 오버라이드만 지운다 — `hidden` 은 그동안 한 번도 틀린 적이 없으니 + 지우자마자 바로 맞다. 방금 누른 진짜 탭의 스타일도 직접 켠다(React 리렌더가 + 생략돼도 항상 맞게). +4. **링크 걸기** — 일정 정거장 187개 · 엽서 4개를 네이버 지도로 +5. **사진 저작자 표시** — 이 목업이 물고 있는 번들이 옛 판이라 `imageCredit` 을 안 그린다 → T3 +6. **카로셀 제어** — 아래 +7. **날씨 카드 문구 순환** (`startWeatherNote`) — 하늘 5 · 기온대 5, 케이스마다 다섯 벌을 + 자루에서 뽑아 20초마다 갈아 끼운다. 렌더러는 케이스마다 한 칸씩만 읽는다. + 하늘이 다섯인 것도 여기서만 가능하다 — 카드는 넷으로만 가르므로 구름많음(1~3)과 + 그 외(안개·소나기·뇌우…)가 `notes.흐림` **한 칸**을 같이 본다 → **2.3** +8. **가요 다방 판꽂이** (`inject.css` + `inject.js` `startSongsRail()`) — 렌더러는 판을 + `flex-wrap` 으로 편다. 4곡일 때 만든 모양이라 25곡이면 세 줄, 50곡이면 여섯 줄짜리 + 음반 진열대가 된다. 한 줄 가로 스크롤로 눕힌다(넘치면 옆으로 민다). **곡명은 항상 + 보인다** — 렌더러가 원래 판 아래에 그리는 그 글자를 그대로 두고 줄 폭만 맞춘다. + ★ 한 번 이 자리를 hover 로만 곡명이 뜨게 고친 적이 있다(2026-09-14, 판을 겹쳐 꽂고 + 손을 얹거나 고른 판에만 이름표를 띄우는 식). 데스크톱 벽은 없앴지만 모바일엔 hover 가 + 없어 **곡명이 아예 안 보이는 화면**이 됐다 — 데스크톱만 보고 고친 값이었다(같은 날 대표 + 지적으로 되돌림). + ★ **화살표 + 실제 카로셀**(같은 날: "옆으로 갈수있는 버튼도 주고 slider.js 적용해줘"). + 렌더러가 이 판 목록을 `Carousel.tsx` 로 안 감싸서(그냥 `flex flex-wrap` 상자다) + "군산 읽기" 처럼 기존 `.slider-viewport` 를 마크업에서 못 가져다 쓴다 — 그래서 + `startSongsRail()` 이 그 상자(`:has()` 로 잡는 판 목록 div) 하나를 통째로 새 상자로 + 감싸 `.slider-viewport` 를 붙이고, 그 위에 embla-carousel 코어(`vendor/embla-carousel.umd.js`, + 군산 읽기와 같은 라이브러리)를 얹는다. **판 버튼 25개를 옮기지 않고 그 부모 하나만 + 옮긴다** — React 는 그 노드를 그대로 갖고 있으니(`setPlaying` 이 속성만 고친다) 감싼 + 뒤에도 클릭이 그대로 먹는다. + ★ **마크업을 고치지 않는다**(React 가 되돌린다) — `:has()` 로 판이 든 상자만 집는다. + → 렌더러도 같은 문제다(**3.4**) +9. **엽서 쓰기** (`postcardMaker()`, `#w4d-pm-section`) — "오늘의 엽서" 탭을 비우고 + (`patch_stay.py` 가 `postcard` 섹션 `data` 를 빈 `items:[]` 로 덮는다), 그 자리 대신 + **손님이 직접 문구를 써서 자기 것으로 저장·공유하는 카드**를 `festival` 섹션 바로 앞에 + 독립 섹션으로 얹는다(2026-09-14: "오늘의 엽서 칸을 빼고 이 엽서 포맷으로…이건 축제소개 + 전에 섹션으로 빼줘"). 캔버스(1080×1080)에 숙소 사진(`P.media` 전체, 카로셀로 고른다) + + 문구 + 우표·소인을 그려 PNG 로 만들고, `navigator.share({files:[...]})` (Web Share API) + 로 공유하거나 다운로드한다 — 새 익명 업로드 백엔드를 만들지 않으려고 파일 공유 쪽을 골랐다 + (`solution/backend` 미디어 라우터는 전부 로그인 필요, `IsValidAccessToken`). + - 공유 버튼 문구는 기기별로 다르다 — `matchMedia('(pointer: coarse)')` 로 터치 기기에서만 + "카카오톡 등으로 공유", 나머지(주로 macOS 데스크톱)는 "공유하기". macOS 는 카카오톡이 + 시스템 공유 확장을 안 만들어 시트에 메시지·메모 정도만 뜨는 게 OS 사양이라 그렇다. + - 상호명(placeName)·소인 텍스트·플레이스홀더 전부 하드코딩 안 한다 — 다른 숙소·카페도 + 이 스크립트를 그대로 쓸 수 있어야 해서다("스테이머뭄뿐만 아니라 다른 숙박업소나 카페도 + 써야 하는데"). 소인은 `stampLines(name, maxWidth)` 가 콤마·공백 경계로, 안 되면 + 글자 단위로 접는다. + - ★ **줄바꿈이 폭을 안 넘던 버그** (2026-09-14 대표 제보 — 스크린샷: 공백 없이 이어 친 + 글자열이 우표 칸까지 넘쳐 나감). `wrapLines()` 가 **공백 단위로만** 접어서, 단어 하나가 + 이미 칸보다 넓으면 아예 안 잘랐다. → `stampLines()` 와 같은 글자 단위 폴백을 추가했다. + - ★ **4줄 제한** (2026-09-14 대표: "4줄이상 못쓰게 막으라고"). 글자 수로는 몇 줄이 될지 + 못 잰다(줄바꿈은 폭·서체에 달렸다) — `textarea` 의 `input` 이벤트마다 `wrapLines()` 로 + **실제로 접히는 줄 수**를 재서, 4줄을 넘으면 방금 입력을 되돌린다(엔터를 여러 번 눌러도, + 자음만 이어 쳐도 똑같이 막힌다). `draw()` 쪽에도 4줄로 자르고 말줄임표(…)를 붙이는 + 방어선을 하나 더 둔다 — 붙여넣기처럼 입력 쪽 검사를 안 거치는 경로까지 대비한다. ### 카로셀 제어 (`tameRails`) @@ -765,7 +979,7 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/" ★ **React 가 다시 그리면 사라진다.** 그래서 - 플레이어 목록·패널은 `#root` **밖**(body 직속)에 둔다 - 헤더에 꽂는 단추는 `MutationObserver` 로 감시해 다시 꽂는다 -- 캐치프레이즈는 노드를 들고 있지 않고 **매 순환마다 다시 찾는다** +- 캐치프레이즈 줄은 노드를 들고 있지 않고 **매 순환마다 붙어 있는지 보고, 없으면 대표 문구를 다시 찾아 새로 붙인다** ★ **`window.load` 를 기다리지 않는다.** 경로 지도 때문에 OSM 타일 `` 가 140장이라 `load` 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다. @@ -787,6 +1001,15 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/" 바꾸는 것이라, SSR 마크업과 `llms.txt` 는 구운 날 그대로다. 시연에는 문제없지만 검색·AI 에 인용되는 값은 아니다. - **`og:image`** 도 마찬가지다. 화면은 새 사진, 카톡 공유 썸네일만 옛것이다. +- **React 가 `hidden`·`aria-selected`·style 로 계산해 주는 속성은 주입분이 직접 안 건드린다.** + 건드리면 그 값과 React 내부 상태(`useState`)가 어긋나고, 손님이 **같은 값으로 되돌리는 + 클릭**(예: 방금까지 활성이던 탭을 다시 누름)을 하면 `setState` 가 같은 값이라 **리렌더를 + 생략**해 버려(bail-out) 주입분이 덮은 값이 영영 안 돌아온다(2026-09-14, 군산 읽기 탭 + 버그 — **2.5 항목 3**). React 가 그 요소에 아예 안 쓰는 속성(`style.display` 등)으로만 + 가리고, 걷을 때는 그 오버라이드만 지운다. +- **캔버스 줄바꿈은 공백 기준만으로는 모자란다.** 단어 하나가 이미 칸보다 넓으면 + (예: 공백 없이 이어 친 자·모음) 안 잘리고 그대로 넘친다 — 글자 단위 폴백을 같이 둬야 + 한다(`wrapLines()`·`stampLines()`, **2.5 항목 9**). --- --- @@ -799,7 +1022,7 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/" 1. **실행하지 않은 것을 됐다고 쓰지 않는다.** 코드에서 빠진 것을 보는 건 확인이 아니다. 화면을 열어 세거나 명령을 돌려서 **숫자·출력**을 얻은 뒤에 말한다. -2. **"했다" 옆에 무엇으로 확인했는지 같이 쓴다.** `audit-all.mjs 34/34` · `md5 일치` · +2. **"했다" 옆에 무엇으로 확인했는지 같이 쓴다.** `audit-all.mjs 38/38` · `md5 일치` · `20초 동안 0px` 처럼. 근거를 못 쓰면 아직 안 끝난 것이다. 3. **절차를 안내할 때는 그 절차를 먼저 밟아 본다.** 4. **일부만 했으면 일부만 했다고 쓴다.** 못 한 것을 빼고 요약하지 않는다. @@ -861,15 +1084,19 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/" |---|---| | `README.md` | **이 문서.** 지침은 전부 여기 | | `TEXT.md` | 화면에 나가는 텍스트 전문 (생성물 — `python3 dump_text.py`) | -| `build_itinerary.py` | 여행 일정 계산 + 규칙 16종 감사 | +| `build_itinerary.py` | 여행 일정 계산 + 규칙 17종 감사 | | `build_story.py` | 가요 다방 · 인물 열전 + 위키 사진 | -| `patch_stay.py` | 캐치프레이즈 · 객실 · 자작곡 · HTML 조립 · 자산 경로 교정 | +| `build_reading.py` | 군산 읽기 — 카로셀 34꼭지(문학·섬과 바다·역사·장소·음식), 화면엔 매번 5~6개 무작위 | +| `build_daily.py` | 오늘의 한 장 — 노래·문학 일력 39장 | +| `patch_stay.py` | 캐치프레이즈 · 주변 맛집 · 객실 · 자작곡 · HTML 조립 · 자산 경로 교정 | +| `fetch_restaurants.py` | 주변 맛집을 TourAPI 2km 로 다시 받아 `restaurants.json` · 사진은 `img/mirror/` (굽기와 따로, 한 번만) | +| `fill_restaurant_photos.py` | TourAPI 에 사진이 없는 맛집 11곳 사진을 받아 1600px 로 줄여 둔다 — ⚠ **공공누리 아님, 시연본 전용** | | `dump_text.py` | `TEXT.md` 생성 | | `inject.css` · `inject.js` | 화면에 덧대는 다섯 가지 | -| `audit-all.mjs` | 브라우저로 34개 항목 전수 점검 | -| `rails-test.mjs` | 카로셀 13개 × 데스크톱·모바일 | +| `audit-all.mjs` | 브라우저로 47개 항목 전수 점검 | +| `rails-test.mjs` | 카로셀 13개 × 데스크톱·모바일. 모바일에는 이름 없는 가로 상자 3개가 더 잡힌다 — 그중 하나가 가요 다방 판꽂이다(자동으로 안 넘어가므로 전 칸 0 이 맞다) | | `kingcheck.mjs` | 킹서버 반영분 확인 | -| `audit_schedule.py` | 일정 전수 검수표 생성 → `SCHEDULE.md` (규칙이 안 보는 논리 12가지) | +| `audit_schedule.py` | 일정 전수 검수표 생성 → `SCHEDULE.md` (규칙이 안 보는 논리 13가지) | | `SCHEDULE.md` | 그 결과물 (생성물) | ### 원본 · 원자료 (건드리지 않는다) @@ -878,6 +1105,8 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/" |---|---| | `stay-payload.json` · `orig/index.html` | **원본.** 되돌릴 때 기준 | | `rooms.json` | 야놀자 등록본에서 뽑은 객실 사진 목록 (A동 12 · B동 10) | +| `restaurants.json` | 주변 맛집 31곳 — 원본 8 + TourAPI 2km 23 (사진 20) | +| `restaurant-photos.json` | 사진 없던 맛집 11곳의 사진 출처 주소·게시물 제목. 굽기가 사진 없는 집에만 덧댄다 | | `people-photos.json` · `people-images.json` · `people-images-meta.json` | 위키 문서 사진 + 라이선스 | | `people-raw.json` · `people-rows.json` | 인물 수집 원자료 | | `coords.json` | 카카오 로컬에서 받은 좌표 | @@ -890,7 +1119,7 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/" | `img/people/` | 17장 | 위키 사진. 저작자 표시가 라이선스 조건이라 같이 관리한다 | | `img/mirror/` | 61장 | 히어로·소개·객실. 없으면 **다른 기계에서 흰 화면** | | `audio/` | 5곡 | 사장님 자작곡 | -| `vendor/` | 2개 | 번들 css/js. 해시가 달라도 뜨게 하려고 | +| `vendor/` | 3개 | 번들 css/js(해시가 달라도 뜨게 하려고) + `embla-carousel.umd.js`(군산 읽기·가요 다방 카로셀이 쓰는 코어, `node_modules/embla-carousel` 그대로 복사) | 합쳐서 약 77M 이다. 전부 **한 번 넣고 안 바뀌는 파일**이라 git 이 싫어하는 경우가 아니다. 자산은 커밋을 따로 떼어 뒀다 — 히스토리에서 덩치를 걷어낼 일이 생기면 그 커밋만 건드린다. @@ -935,4 +1164,7 @@ ssh King_admin "rm -rf ~/data2/o2o-site-AEO/tmp-stay-deploy" | 인물 사진 18/57 | 위키 문서에 사진이 있는 사람이 18명뿐이다. 동명이인 함정 때문에 이름 검색으로 늘리지 않는다 | | 렌더러의 카로셀 자동 넘김 | 운영 사이트에 그대로 있다. 고치려면 `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` 는 이 목업에서 손으로 적은 것이고, 사이트마다 자동으로 나오려면 생성 잡이 하나 필요하다 | +| 오늘의 한 장 39장 (365일 중 39일) | 확인되는 소재가 거기까지다. 받은 `gunsan_365_story_db.xlsx` 는 365행이지만 실제 주제가 52개(한 주제를 운영 슬롯 7가지로 복제)이고 231행이 `검증필요`, 시·수필은 빈 슬롯이다. 더 채우려면 **군산을 소재로 한 시·수필을 출처와 함께 찾아야** 한다 | | 일정을 이미지/링크로 공유 | 이미지 저장은 가능(카드를 PNG 로). 링크는 지금 섹션까지만 간다 — 탭·코스가 URL 에 안 남는다 | diff --git a/solution/site/scripts/mockup/TEXT.md b/solution/site/scripts/mockup/TEXT.md index cca1fe4..ae4aefd 100644 --- a/solution/site/scripts/mockup/TEXT.md +++ b/solution/site/scripts/mockup/TEXT.md @@ -218,7 +218,7 @@ | 56 | | 전미라 | 1978– | 테니스 선수 | 테니스 선수로 뛰었고 지금은 해설위원입니다. | | 57 | | 한왕용 | 1966– | 산악인 | 히말라야 8000m 14좌를 세계 11번째로 완등했습니다. | -## 4. 일정 — 정거장에서 할 일 36줄 — `build_itinerary.py` `PLACES`·`STAY_ACTION` +## 4. 일정 — 정거장에서 할 일 37줄 — `build_itinerary.py` `PLACES`·`STAY_ACTION` | 장소 | 무엇을 하는지 | 머무는 시간 | |---|---|---| @@ -240,10 +240,11 @@ | 물밀소 | 저온숙성 빵을 골라 나옵니다. 마당에서 먹을 것으로 두어 개 더 삽니다. | 40분 | | 빈해원 | 2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다. | 70분 | | 스테이 머뭄 | 돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다. | 30분 | -| 스테이 머뭄 | 맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다. | 40분 | +| 스테이 머뭄 | 맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다. | 42분 | | 스테이 머뭄 | 부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다. | 60분 | | 스테이 머뭄 | 숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다. | 173분 | -| 스테이 머뭄 | 아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요. | 75분 | +| 스테이 머뭄 | 아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요. | 79분 | +| 스테이 머뭄 | 아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다. | 75분 | | 스테이 머뭄 | 오늘은 나가지 않습니다. 마당 평상과 창고형 카페 공간에서 책을 읽고 낮잠을 잡니다. | 223분 | | 스테이 머뭄 | 짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다. | 45분 | | 우체통거리 | 주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다. | 26분 | @@ -285,17 +286,200 @@ | 소설 『탁류』를 따라 걷는 2박 3일 | 2박 3일 | 채만식을 읽어 본 손님 | 소설의 순서대로 사흘을 나눴습니다. 선창에서 시작해 문학관에서 끝냅니다. | | 철길마을과 항구를 같이 보는 2박 3일 | 2박 3일 | 사진을 찍는 손님 | 기찻길과 물길을 하루씩 나눠 봅니다. 마지막 날은 건물만 봅니다. | -## 6. 오늘의 엽서 4장 — 원본 payload (아직 다시 안 뽑음) +## 6. 군산 읽기 31가지 — `build_reading.py` `ITEMS` + +한 화면에 다 펴 두는 카드. 일력(7번)과 달리 손님이 훑어 내려가며 읽는 자리다. + +### 문학 8가지 — 이 도시를 쓴 사람과 그 자리 + +| 제목 | 글 | 연도 | 확인 | 출처 | +|---|---|---|---|---| +| 채만식 『탁류』 | 일제강점기 군산을 무대로 한 장편소설입니다. 금강 하구의 흙탕물처럼 시대에 휩쓸리는 사람들을 그렸습니다. | 1937 | 확인 | 군산시 채만식문학관 | +| 백릉 채만식 | 1902년 옥구(지금의 군산) 임피에서 태어난 소설가입니다. 풍자로 시대를 그렸고 1950년에 세상을 떠났습니다. | 1902 | 확인 | 군산시 채만식문학관 | +| 채만식문학관 | 금강 하구 옆에 선 문학관입니다. 작가의 생애와 『탁류』의 판본을 한자리에서 봅니다. | | 확인 | 군산시 채만식문학관 | +| 『탁류』라는 제목 | 금강 하류의 흙탕물을 가리키는 말입니다. 그 물살에 휩쓸려 가는 사람들을 함께 가리킵니다. | | 확인 | 군산시 채만식문학관 | +| 채만식의 풍자 | 웃기면서 찌르는 문장이 이 작가의 방식입니다. 개항장 군산에서 본 것이 그 바탕에 있습니다. | | 확인필요 | 군산시 채만식문학관 | +| 탁류길 | 소설의 배경을 따라 걷는 군산 스탬프투어 코스입니다. 원도심 구간이라 머뭄에서 걸어 닿습니다. | | 확인 | 군산 스탬프투어 | +| 조정래 『아리랑』과 아리랑길 | 군산과 김제 들녘이 무대인 대하소설입니다. 스탬프투어에 같은 이름의 코스가 있습니다. | | 확인필요 | 군산 스탬프투어 | +| 군산이 낳은 시인들 | 고은 · 이병훈 · 심호택 · 문효치 · 이연주 · 주하림. 아래 '인물 열전' 에서 한 사람씩 봅니다. | | 확인 | 한국어 위키백과 | + +### 섬과 바다 6가지 — 다리를 건너면 나오는 것 + +| 제목 | 글 | 연도 | 확인 | 출처 | +|---|---|---|---|---| +| 고군산군도 | 예순 남짓한 섬이 모인 군도입니다. 새만금 방조제와 연결 다리로 이어져 차로 들어갑니다. | | 확인 | 군산시 문화관광 | +| 선유도 | 고군산군도의 대표 섬입니다. 망주봉 아래 해변을 걷고 스카이라인을 탑니다. 머뭄에서 차로 한 시간 남짓입니다. | | 확인 | 군산시 문화관광 | +| 장자도 | 선유도와 다리로 이어진 작은 섬입니다. 포구와 바다길을 봅니다. | | 확인필요 | 군산시 문화관광 | +| 무녀도 | 고군산군도의 섬 하나입니다. 이름의 유래가 전해집니다. | | 확인필요 | 군산시 문화관광 | +| 신시도 | 새만금과 고군산군도를 잇는 자리의 섬입니다. | | 확인필요 | 군산시 문화관광 | +| 비응항 | 새만금 방조제가 시작하는 항구입니다. 서해 관광의 관문으로 씁니다. | | 확인필요 | 군산시 문화관광 | + +### 역사 5가지 — 연표에 안 들어간 이야기 + +| 제목 | 글 | 연도 | 확인 | 출처 | +|---|---|---|---|---| +| 미곡 수탈과 군산항 | 일제강점기 호남평야의 쌀이 이 항구로 모여 일본으로 나갔습니다. 부잔교와 창고가 그 통로였습니다. | | 확인필요 | KCI 학술자료 | +| 째보선창 | 내항 옆 선창입니다. 『탁류』의 인물들이 오가던 자리이고, 지금은 배 대신 낡은 창고가 남았습니다. | | 확인필요 | 군산시 문화관광 | +| 해망굴 | 시내와 항구를 잇는 굴입니다. 걸어서 통과해 반대편 항구 쪽으로 나옵니다. | | 확인필요 | 군산시 문화관광 | +| 경암동 철길 | 1944년에 놓인 철길입니다. 선로가 집 앞 골목을 그대로 지나갑니다. | 1944 | 확인 | 군산시 문화관광 | +| 군산항 | 만남과 이별, 물류와 근대사가 한자리에 겹치는 항구입니다. 도시의 성격이 여기서 정해졌습니다. | | 확인 | 군산시 문화관광 | + +### 장소 9가지 — 걸어서 또는 차로 닿는 자리 + +| 제목 | 글 | 연도 | 확인 | 출처 | +|---|---|---|---|---| +| 군산근대역사박물관 | 항구도시의 근대사를 3층부터 내려오며 봅니다. 도시를 걷기 전에 여기서 한 번 잡고 나섭니다. | | 확인 | 군산시 문화관광 | +| 군산 내항과 부잔교 | 조수에 따라 오르내리는 뜬다리입니다. 다리 위를 끝까지 걸어 나갔다 돌아옵니다. | 1926 | 확인 | 군산시 문화관광 | +| 동국사 | 일본식 사찰 건축이 남은 절입니다. 대웅전과 마당을 둘러봅니다. | | 확인필요 | 군산시 문화관광 | +| 월명공원 | 시내와 바다를 함께 내려다보는 공원입니다. 계절마다 오르는 길이 다릅니다. | | 확인 | 군산시 문화관광 | +| 은파호수공원 | 호수를 두르는 산책길입니다. 저녁에 한 바퀴 돌기 좋습니다. | | 확인 | 군산시 문화관광 | +| 금강하굿둑 | 금강과 서해가 만나는 자리입니다. 강과 바다의 경계를 사람 손으로 그은 곳입니다. | 1990 | 확인필요 | 군산시 문화관광 | +| 말랭이마을 | 언덕과 골목이 남은 마을입니다. 도시재생으로 되살린 자리입니다. | | 확인필요 | 군산시 문화관광 | +| 장미갤러리 | 근대역사문화거리의 전시 공간입니다. 옛 창고 건물을 고쳐 씁니다. | | 확인필요 | 군산시 문화관광 | +| 진포해양테마공원 | 진포대첩의 기억을 항구 옆에 펼쳐 둔 공원입니다. 실제 군함과 항공기를 봅니다. | | 확인필요 | 군산시 문화관광 | + +### 음식과 생활 3가지 — 이 도시가 먹는 것 + +| 제목 | 글 | 연도 | 확인 | 출처 | +|---|---|---|---|---| +| 군산 짬뽕 | 항구도시의 중국집 문화가 남았습니다. 빈해원과 국제반점이 그 자리입니다. | | 확인필요 | 군산시 문화관광 | +| 박대와 군산 밥상 | 서해에서 나는 박대를 말려 구워 냅니다. 백반집 상에 자주 오릅니다. | | 확인필요 | 군산시 문화관광 | +| 이성당 | 오래된 빵집입니다. 단팥빵과 야채빵이 나오는 시간에 맞추면 줄이 짧습니다. | | 확인 | 대한민국 구석구석 | + +## 7. 오늘의 한 장 39장 — `build_daily.py` + +날마다 한 장씩 넘어가는 일력. 가요는 가요 다방을 그대로 읽고, 시·소설은 손으로 적는다. + +| 날짜 | 분류 | 제목 | 글 | 확인 | 출처 | +|---|---|---|---|---|---| +| 01-01 | 문학 | 채만식 『탁류』 | 일제강점기 군산을 무대로 한 장편소설입니다. 금강 하구의 흙탕물처럼 시대에 휩쓸리는 사람들을 그렸습니다. | 확인 | 군산시 채만식문학관 | +| 01-10 | 대중가요 | 「잘 있거라 군산항」 · 안다성 | 1963년 곡. 작사 손석우 · 작곡 손석우. 월명산 고갯길로 시작하는 이별가. 군산을 노래한 곡 가운데 가장 널리 알려졌습니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 | +| 01-20 | 문학 | 백릉 채만식 | 1902년 옥구(지금의 군산) 임피에서 태어난 소설가입니다. 풍자로 시대를 그렸고 1950년에 세상을 떠났습니다. | 확인 | 군산시 채만식문학관 | +| 01-29 | 대중가요 | 「헤어진 군산항」 · 박제연 | 1965년 곡. 작사 송운선 · 작곡 송운선. 비 내리는 밤 부두에서 헤어지는 노래입니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 | +| 02-07 | 대중가요 | 「님 떠난 군산항」 · 이미자 | 1966년 곡. 작사 조형식 · 작곡 백영호. 떠나는 배를 보내는 항구의 정서를 담았습니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 | +| 02-17 | 문학 | 채만식문학관 | 금강 하구 옆에 선 문학관입니다. 작가의 생애와 『탁류』의 판본을 한자리에서 봅니다. | 확인 | 군산시 채만식문학관 | +| 02-26 | 대중가요 | 「금강 나그네」 · 이미자 | 1966년 곡. 작사 이병훈 · 작곡 박춘석. 금강 하구와 군산항 부두를 배경으로 한 곡입니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 | +| 03-08 | 대중가요 | 「잘 있거라 군산항구」 · 최정자 | 1967년 곡. 작사 최치수 · 작곡 유금춘. 떠나는 사람의 말로 부르는 이별가입니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 | +| 03-17 | 문학 | 『탁류』라는 제목 | 금강 하류의 흙탕물을 가리키는 말입니다. 그 물살에 휩쓸려 가는 사람들을 함께 가리킵니다. | 확인 | 군산시 채만식문학관 | +| 03-26 | 대중가요 | 「군산 마도로스」 · 이상열 | 1967년 곡. 항구를 드나들던 뱃사람의 노래입니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 | +| 04-05 | 대중가요 | 「군산 부르스」 · 유주용 | 1969년 곡. 작사 반야월 · 작곡 강도미. 1960년대 말 군산 거리의 분위기를 담은 곡입니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 | +| 04-14 | 문학 | 채만식의 풍자 | 웃기면서 찌르는 문장이 이 작가의 방식입니다. 개항장 군산에서 본 것이 그 바탕에 있습니다. | 확인필요 | 군산시 채만식문학관 | +| 04-23 | 대중가요 | 「이별의 군산항 부두」 · 최정자 | 1969년 곡. 작사 최치수 · 작곡 유금춘. 부두에서의 이별을 다시 한 번 부른 곡입니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 | +| 05-03 | 문학 | 탁류길 | 소설의 배경을 따라 걷는 군산 스탬프투어 코스입니다. 원도심 구간이라 머뭄에서 걸어 닿습니다. | 확인 | 군산 스탬프투어 | +| 05-12 | 대중가요 | 「군산항 부르스」 · 최하우 | 1984년 곡. 작사 최하우 · 작곡 김성근. 1980년대에 나온 군산항 노래입니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 | +| 05-21 | 대중가요 | 「나의 사랑 군산이여」 · 오단비 | 1989년 곡. 작사 이수 · 작곡 정종택. 군산을 향한 애정을 정면으로 부르는 곡입니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 | +| 05-31 | 문학 | 조정래 『아리랑』과 아리랑길 | 군산과 김제 들녘이 무대인 대하소설입니다. 스탬프투어에 같은 이름의 코스가 있습니다. | 확인필요 | 군산 스탬프투어 | +| 06-09 | 대중가요 | 「군산항 엘레지」 · 오단비 | 작사 이수 · 작곡 정종택. 항구 도시의 비가입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 | +| 06-18 | 대중가요 | 「선유도 아가씨」 · 오단비 | 작사 이수 · 작곡 서승일. 고군산군도 선유도를 배경으로 한 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 | +| 06-28 | 문학 | 군산이 낳은 시인들 | 고은 · 이병훈 · 심호택 · 문효치 · 이연주 · 주하림. 아래 '인물 열전' 에서 한 사람씩 봅니다. | 확인 | 한국어 위키백과 | +| 07-07 | 대중가요 | 「군산찬가」 · 오단비 | 작사 이수 · 작곡 서승일. 은파와 새만금이 가사에 나오는 군산 찬가입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 | +| 07-17 | 대중가요 | 「오성산」 · 오단비 | 작사 이수 · 작곡 서승일. 금강 하구를 내려다보는 오성산을 노래했습니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 | +| 07-26 | 시 | 시인 고은 | 1933– · 군산에서 태어나 60년 넘게 시를 써 온 시인입니다. | 확인 | 한국어 위키백과 | +| 08-04 | 대중가요 | 「함께하는 군산」 · 오은주 | 작사 김동찬 · 작곡 김동찬. 군산시가 제작한 음반의 첫 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 | +| 08-14 | 대중가요 | 「새만금 연가」 · 채미영 | 작사 김동찬 · 작곡 김동찬 · 김수환. 새만금을 소재로 한 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 | +| 08-23 | 시 | 시인 이병훈 | 1925–2009 · 옥구(지금의 군산) 출신으로 1950년대에 등단한 현대 시인입니다. | 확인 | 한국어 위키백과 | +| 09-01 | 대중가요 | 「군산시민의 노래」 · 오단비 | 작사 군산문화원 · 작곡 군산예총. 금만경 들녘과 서해 바다로 시작하는 시민의 노래입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 | +| 09-11 | 시 | 시인 심호택 | 1947–2010 · 민중의 삶을 시로 옮긴 시인입니다. | 확인 | 한국어 위키백과 | +| 09-20 | 대중가요 | 「군산 아줌마」 · 박솔미 | 작사 송두호 · 작곡 송두호. 선창가 거리와 새만금이 함께 나오는 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 | +| 09-29 | 대중가요 | 「내 고향 군산항」 · 정애란 | 작사 정애란 · 작곡 정애란. 고향으로서의 군산항을 부른 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 | +| 10-09 | 시 | 시인 문효치 | 1943– · 군산에서 태어나 1966년 신춘문예로 등단했습니다. | 확인 | 한국어 위키백과 | +| 10-18 | 대중가요 | 「군산 가는 길」 · 황상준 | 군산으로 향하는 길을 제목으로 삼은 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 | +| 10-27 | 대중가요 | 「군산 추억」 · 임인건 | 재즈 피아니스트 임인건이 군산을 소재로 만든 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 | +| 11-06 | 시 | 시인 이연주 | 1953–1992 · 짧게 활동하고 떠난 시인입니다. | 확인 | 한국어 위키백과 | +| 11-15 | 대중가요 | 「군산에서」 · 임인건 | 같은 연주자가 군산에서 받은 인상을 담았습니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 | +| 11-25 | 대중가요 | 「헤어진 군산항」 · 김창환 | 작사 송운선 · 작곡 송운선. 군산시 제작 음반에 다시 실린 판입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 | +| 12-04 | 시 | 시인 주하림 | 군산에서 태어나 2009년 창비신인시인상으로 등단했습니다. | 확인 | 한국어 위키백과 | +| 12-13 | 대중가요 | 「금강 아가씨」 · 이미자 | 금강을 배경으로 한 이미자의 또 다른 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 | +| 12-23 | 대중가요 | 「금강의 비가」 · 전승애 | 금강을 제목에 올린 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 | + +## 8. 오늘의 엽서 4장 — 원본 payload (아직 다시 안 뽑음) - 부잔교 위에서는 물이 어디까지 올라왔었는지가 다리에 새겨져 있다. `#군산 #내항 #부잔교` - 짬뽕 한 그릇이 항구도시의 이력서인 줄은 몰랐다. `#군산짬뽕 #항구도시` - 단팥빵을 사러 줄을 서는 일이 여행이 되는 도시. `#이성당 #오래된가게` - 섬과 섬 사이를 다리로 건너면, 바다가 길이 된다. `#고군산군도 #선유도` -## 7. 머뭄이 만든 노래 5곡 — `patch_stay.py` `OWN_SONGS` +## 9. 머뭄이 만든 노래 5곡 — `patch_stay.py` `OWN_SONGS` - 머뭄의 아침 — `/s/stay/audio/01-mumum-morning.mp3` - CHECK IN · 머뭄 — `/s/stay/audio/02-check-in.mp3` - Stay in 머뭄 — `/s/stay/audio/03-stay-in.mp3` - 오늘은 안 갈란다 — `/s/stay/audio/04-not-going.mp3` - 군산에 두고 온 님 — `/s/stay/audio/05-gunsan-nim.mp3` + +## 10. 오늘의 날씨 문구 50개 — `patch_stay.py` `SKY_NOTES`·`TEMP_NOTES` + +하늘 줄은 **집 안**에서 뭘 할지, 기온 줄은 **밖에서** 어디를 갈지다. 두 줄이 나란히 선다. +### 하늘 25개 (`SKY_NOTES`) + +**맑음** +- 마당 평상에 앉으면 새소리만 들립니다. 정원 쪽으로 그늘이 길게 눕는 시간입니다. +- 기와가 마르는 냄새가 나는 날입니다. 아침에 널어 두신 것은 오후면 다 마릅니다. +- 볕이 좋아 대문을 열어 둡니다. 마당 사진은 해가 기울기 시작할 때가 가장 곱습니다. +- 창을 다 열어도 좋은 날입니다. A동 장지문으로 드는 빛이 오전에 가장 깊습니다. +- 해가 길게 남는 날입니다. 짐을 풀고 골목부터 한 바퀴 돌고 오셔도 늦지 않습니다. + +**구름많음** +- 빛이 부드러운 날입니다. 골목 사진이 제일 잘 나오는 빛입니다. +- 그늘이 옅어 마당이 눈부시지 않습니다. 평상에 오래 앉아 계시기 좋습니다. +- 해가 들었다 숨었다 합니다. 창고형 CAFÉ 공간과 마당을 오가며 쉬시면 됩니다. +- 구름이 얇은 날입니다. 우체통거리까지 걷기에 덥지도 춥지도 않습니다. +- 빛이 고른 날입니다. 초원사진관까지 걸어서 삼 분, 사진은 오늘 같은 하늘이 낫습니다. + +**그외** +- 해가 숨은 날입니다. 기와와 담이 한 색으로 가라앉고, 마당 평상에 앉아도 눈이 부시지 않습니다. +- 하늘이 자주 바뀌는 날입니다. 나가실 때 우산을 챙기시는 편이 낫습니다. +- 안개가 낮게 깔리면 골목 끝이 흐려집니다. 서두르지 마시고 늦게 나가셔도 됩니다. +- 소나기가 지나갈 수 있는 하늘입니다. 창고형 CAFÉ 공간에서 한 차례 보내고 나가셔도 됩니다. +- 바람이 먼저 바뀌는 날입니다. 마당에 널어 두신 것은 들여놓으시는 편이 좋습니다. + +**비** +- 백 년 된 기와를 타고 내리는 소리가 다릅니다. 창고형 CAFÉ 공간에서 빗소리를 들으실 수 있습니다. +- 비 오는 골목에는 사람이 없습니다. 히로쓰 가옥 담길은 오늘 같은 날이 가장 조용합니다. +- 마당 디딤돌이 미끄럽습니다. 밤에 나가실 때는 정원 석등 쪽으로 도세요. +- 실내로 도는 편이 낫습니다. 근대건축관과 군산근대미술관이 걸어서 십 분 안에 나란히 있습니다. +- 우산은 대문 안쪽에 두시면 됩니다. 젖은 것은 테라스 쪽이 잘 마릅니다. + +**눈** +- 기와에 눈이 앉으면 골목이 통째로 조용해집니다. 정원 석등만 켜 두겠습니다. +- 눈 밟는 소리가 담 안에서 크게 들립니다. 마당은 아침에 한 번 쓸어 두겠습니다. +- 눈 오는 날의 원도심은 사람이 적습니다. 초원사진관 골목이 오늘 가장 한산합니다. +- 디딤돌에 눈이 얼 수 있습니다. 밤에 드나드실 때는 조심해서 딛으세요. +- 창을 닫아 두시는 편이 낫습니다. 창고형 CAFÉ 공간이 이런 날 가장 아늑합니다. + +### 기온대 25개 (`TEMP_NOTES`) + +**혹서** +- 낮에는 군산근대미술관·근대건축관처럼 실내로 도는 편이 낫습니다. 월명호수 한 바퀴는 해가 넘어간 뒤가 시원합니다. +- 한낮 골목에는 그늘이 없습니다. 초원사진관·영화의 거리는 이른 아침이나 해 질 무렵에 도세요. +- 옛 군산세관과 근대건축관은 걸어서 이어집니다. 실내를 징검다리 삼아 도시면 덜 지칩니다. +- 경암동 철길마을은 그늘이 적습니다. 오늘은 저녁에 가시는 편이 낫습니다. +- 물은 나가시기 전에 챙기세요. 이성당까지는 걸어서 오백 미터, 해가 기운 뒤가 낫습니다. + +**더움** +- 선유도까지 다녀오기 좋은 기온입니다. 골목은 오후 늦게 도세요 — 경암동 철길마을은 해 질 때가 사람이 적습니다. +- 낮에는 월명호수 그늘길이 시원합니다. 물가를 따라 걷다 돌아오시면 됩니다. +- 우체통거리와 영화의 거리는 그늘이 이어집니다. 걸어서 도시기에 무리가 없습니다. +- 해가 길어 저녁이 넉넉합니다. 내항 쪽으로 나가 노을을 보고 오셔도 됩니다. +- 가장 더운 시간만 실내가 낫습니다. 군산근대미술관에서 한 차례 쉬어 가세요. + +**선선** +- 걷기에 가장 좋은 날입니다. 히로쓰 가옥에서 초원사진관·영화의 거리까지 걸어서 그대로 이어집니다. +- 원도심은 오늘 하루면 다 걸립니다. 군산 시간여행마을까지 천천히 다녀오셔도 됩니다. +- 차를 두고 걷는 편이 낫습니다. 우체통거리에서 옛 군산세관까지 골목이 계속 이어집니다. +- 해가 부담스럽지 않습니다. 경암동 철길마을까지 다녀오기에 알맞은 기온입니다. +- 낮에는 월명호수를 한 바퀴 돌고, 밤에는 마당에 앉아 계셔도 좋습니다. + +**쌀쌀** +- 월명호수를 한 바퀴 돌고 이성당이나 국제반점에서 따뜻한 것을 드시면 알맞습니다. +- 겉옷을 하나 더 챙기세요. 해가 지면 항구 쪽 바람이 먼저 찹니다. +- 실내와 골목을 섞어 도는 편이 낫습니다. 근대건축관에서 몸을 녹이고 다시 나오시면 됩니다. +- 따뜻한 국물이 도는 날입니다. 복성루·빈해원처럼 가까운 집부터 보세요. +- 오후 볕이 짧습니다. 초원사진관·영화의 거리를 먼저 돌고 저녁에 들어오세요. + +**추움** +- 밖은 바람이 매섭습니다. 근대건축관·근대미술관과 시간여행마을 실내를 묶어 도는 편이 낫습니다. +- 항구 바람이 매운 날입니다. 골목은 짧게 돌고 실내를 길게 잡으세요. +- 장갑과 목도리를 챙기세요. 옛 군산세관 앞은 바람이 그대로 지나갑니다. +- 따뜻한 것을 먼저 드시는 편이 낫습니다. 이성당·국제반점은 걸어서 오백 미터입니다. +- 일찍 들어오셔도 됩니다. 벽난로가 있는 거실이 이런 날 가장 좋습니다. diff --git a/solution/site/scripts/mockup/build_daily.py b/solution/site/scripts/mockup/build_daily.py new file mode 100644 index 0000000..8e07e68 --- /dev/null +++ b/solution/site/scripts/mockup/build_daily.py @@ -0,0 +1,152 @@ +"""오늘의 한 장 — 군산의 노래와 문학을 하루에 한 장씩 넘기는 일력. + +★ 왜 이 자리인가 (2026-09-14 대표 의견: "엽서 형식보다 대중가요·시·소설이 더 풍부했으면") + 렌더러에는 `daily`(오늘의 한 장) 탭이 이미 있는데 **payload 에 그 섹션 자체가 없었다** — + 자리는 있고 값이 없어 탭이 서지 않던 자리다(README 1.2). 엽서를 갈아 끼우는 대신 + 빈 자리를 채운다. 엽서는 그대로 둔다. + +★ **이 자리는 도시를 소개하는 자리가 아니다** (같은 날 대표 의견: "오늘의 한 장으로 군산을 + 소개하기는 무리다 — 하루에 하나씩밖에 안 알려 주니까"). 일력은 오늘·어제·내일 세 장만 + 화면에 선다. 군산을 펼쳐 보이는 자리는 `build_reading.py`(군산 읽기)가 맡는다. + 여기는 **날마다 바뀌는 한 장**이고, 그래서 곡·시인·문학을 한 장씩 돌린다. + +★ 받은 자료(`gunsan_365_story_db.xlsx`)를 그대로 싣지 않는다. + 365행이지만 **실제 주제는 52개**고, 한 주제를 '작품 배경 / 역사 맥락 / 계절 연결 / + SNS 한 문장 / 교육용 질문 …' 일곱 가지 운영 슬롯으로 복제해 둔 시드다. 그대로 실으면 + 같은 문장이 일주일에 일곱 번 돈다. 게다가 231행이 `검증필요`, 14행이 `후보`이고 + 시·수필 카테고리는 전부 빈 슬롯이다("…를 찾아 소개하기 위한 **운영용 슬롯**이다") — + 손님 화면에 나갈 문장이 아니라 우리끼리 쓰는 메모다. + → **소재 목록으로만 쓴다.** 화면에 나가는 문장은 여기서 다시 쓰고, 출처는 그 주제를 + 실제로 설명하는 기관 페이지를 단다. + +★ 365장을 억지로 채우지 않는다. 확인되는 소재가 39개면 39장이다 — + 렌더러는 오늘 자리가 비면 **다음 장**을 편다(`DailySection.tsx`). 채우려면 같은 글을 + 여러 날짜에 복사해야 하는데, 그건 인용되려고 만든 사이트에서 할 일이 아니다. + +입력: stay-payload-new.json (build_story.py · build_reading.py 까지 끝난 것 — + 가요 25곡 · 인물 57명 · 문학 8가지를 **전부 거기서 읽는다.** 여기서 손으로 적는 것은 없다) +출력: 같은 파일의 theme.sections 에 `daily` 섹션을 넣는다. +""" +import json +from pathlib import Path + +SP = Path(__file__).parent +SRC = SP / "stay-payload-new.json" + +payload = json.loads(SRC.read_text(encoding="utf-8")) +SECTIONS = payload["theme"]["sections"] +SECT = {s["id"]: s for s in SECTIONS} + +# ── 문학은 '군산 읽기' 표에서 가져온다 ───────────────────────────────────── +# ★ 한 벌만 둔다 (2026-09-14). 문학 카드를 여기에도 적어 두면 한쪽만 고치는 날이 온다 — +# 이 레포에서 프롬프트가 두 곳으로 갈려 서버가 세 종류를 못 만들던 것과 같은 종류의 사고다. +# 손으로 적는 자리는 `build_reading.py` `ITEMS` 하나다. +LIT_GROUP = "문학" + +# ── 시 — 군산이 낳은 시인 ──────────────────────────────────────────────────── +# ★ 시 작품을 싣지 않는다. 확인되는 것은 '이 도시에서 난 시인' 까지다 — +# 받은 자료의 시 카테고리도 전부 빈 슬롯이었고(위 머리말), 군산을 노래한 시를 +# 출처와 함께 확인한 것이 아직 없다. 없는 것을 지어내느니 시인을 세운다. +POETS = ["고은", "이병훈", "심호택", "문효치", "이연주", "주하림"] + + +def _items(section_id: str) -> list[dict]: + section = SECT.get(section_id) + if not section or not section.get("data"): + raise SystemExit(f"{section_id} 섹션이 비었다 — build_story.py · build_reading.py 를 먼저 돌린다") + return json.loads(section["data"])["items"] + + +# ── 가요 25곡 → 25장 ───────────────────────────────────────────────────────── +# 가요 다방(`songs`)에 이미 선 곡을 그대로 읽는다. 목록을 두 벌로 두면 한쪽만 고치게 된다. +song_pages = [] +for song in _items("songs"): + facts = [] + if song.get("year"): + facts.append(f"{song['year']}년 곡") + credit = " · ".join( + part for part in ( + f"작사 {song['lyricist']}" if song.get("lyricist") else "", + f"작곡 {song['composer']}" if song.get("composer") else "", + ) if part) + if credit: + facts.append(credit) + body = ". ".join(facts) + body = f"{body}. {song['story']}" if body else song["story"] + song_pages.append({"category": "대중가요", + "title": f"「{song['title']}」 · {song['artist']}", + "body": body, + "verified": song.get("verified", "확인필요"), + "source": song["source"]}) + +# ── 시인 6명 → 6장 ─────────────────────────────────────────────────────────── +people = {p["name"]: p for p in _items("people")} +poet_pages = [] +for name in POETS: + person = people.get(name) + if not person: + raise SystemExit(f"인물 열전에 {name} 이 없다 — build_story.py 의 PEOPLE 을 본다") + years = f"{person['years']} · " if person.get("years") else "" + poet_pages.append({"category": "시", "title": f"시인 {name}", + "body": f"{years}{person['oneLine']}", + "verified": person.get("verified", "확인필요"), + "source": person["source"]}) + +lit_pages = [{"category": LIT_GROUP, "title": x["title"], "body": x["body"], + # 2026-09-14 부로 build_reading.py 의 ITEMS 에서 verified 필드를 뺐다 + # (대표: "확인필요 없애고 링크는 네이버 링크로") — 여기 기본값은 그 결정을 따른다. + "verified": x.get("verified", "확인"), "source": x["source"]} + for x in _items("reading") if x["group"] == LIT_GROUP] + +# ── 섞고, 날짜를 준다 ──────────────────────────────────────────────────────── +# ★ 같은 분류를 연달아 세우지 않는다 — 일력은 어제·오늘·내일 세 장이 한 화면에 걸린다 +# (`DailySection.tsx`). 가요만 석 장 나란히 서면 일력이 아니라 음반 목록으로 보인다. +others = lit_pages + poet_pages +pages, si, oi = [], 0, 0 +total = len(song_pages) + len(others) +for step in range(total): + take_other = oi < len(others) and (si >= len(song_pages) + or (step + 1) * len(others) / total > oi) + if take_other: + pages.append(others[oi]) + oi += 1 + else: + pages.append(song_pages[si]) + si += 1 + +# ★ 날짜는 **고르게 편다**. 곡이 나온 날·작가의 생일에 맞추고 싶지만 그 날짜를 출처로 +# 확인한 것이 없다 — 지어낸 날짜를 일력에 박으면 틀린 줄 모르고 해마다 틀린다. +for index, page in enumerate(pages): + day = round(index * 365 / len(pages)) + 1 + month, rest = 1, day + for length in (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31): + if rest <= length: + break + rest -= length + month += 1 + page["monthDay"] = f"{month:02d}-{rest:02d}" + +env = {"kind": "daily", "version": 1, "title": "오늘의 한 장", + "subtitle": f"군산의 노래와 문학 {len(pages)}장 — 날마다 한 장씩 넘어갑니다", + "items": [{"monthDay": p["monthDay"], "category": p["category"], "title": p["title"], + "body": p["body"], "verified": p["verified"], "source": p["source"]} + for p in pages]} + +# 없으면 만든다 — 원본 payload 에는 `daily` 섹션 자체가 없다(그래서 탭이 안 섰다). +if "daily" not in SECT: + # 형제(가요 다방) 옆에 둔다. 탭 순서는 렌더러가 정하므로 배열 순서는 보기 좋으라고 맞추는 것이다. + SECTIONS.insert(next(i for i, s in enumerate(SECTIONS) if s["id"] == "songs") + 1, + {"id": "daily", "name": "오늘의 한 장", "enabled": False, "locked": False}) + SECT = {s["id"]: s for s in SECTIONS} +SECT["daily"]["data"] = json.dumps(env, ensure_ascii=False) + +SRC.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + +counts: dict[str, int] = {} +for page in pages: + counts[page["category"]] = counts.get(page["category"], 0) + 1 +unverified = sum(1 for p in pages if p["verified"] != "확인") +print(f"오늘의 한 장 {len(pages)}장 — " + + " · ".join(f"{k} {v}" for k, v in counts.items()) + + f" (확인필요 {unverified}장)") +print(f" 날짜 {pages[0]['monthDay']} … {pages[-1]['monthDay']}") diff --git a/solution/site/scripts/mockup/build_reading.py b/solution/site/scripts/mockup/build_reading.py new file mode 100644 index 0000000..b584efe --- /dev/null +++ b/solution/site/scripts/mockup/build_reading.py @@ -0,0 +1,301 @@ +"""군산 읽기 — 이 도시를 갈래로 묶어 카로셀로 보여주는 소재 더미. + +★ 왜 만들었나 (2026-09-14 대표 의견: "오늘의 한 장으로 군산을 소개하기는 무리다, + 하루에 하나씩밖에 안 알려 주니까 — 엑셀을 보고 다시 짜라") + 일력(`build_daily.py`)은 오늘·어제·내일 **세 장만** 화면에 선다. 나머지는 문서에만 남아 + 검색·AI 는 읽어도 손님은 못 본다. 도시를 소개하는 자리로는 그릇이 작다. + 그래서 받은 자료(`gunsan_365_story_db.xlsx`)의 주제를 **갈래로 묶는다.** + 일력은 그대로 두되, 이제 '매일 바뀌는 한 장' 이지 소개 자리가 아니다. + +★ 화면 구성은 이 파일이 정하지 않는다 — **payload 는 갈래 전체를 그대로 담고**, + '한 화면에 몇 개를 어떻게 보여줄지' 는 `inject.js` `startReading()` 이 정한다. + 처음엔 31개를 전부 세로로 펼쳤는데 "스크롤 압박" 이 됐다(같은 날 대표 의견) — + 갈래마다 3~4개를 무작위로 뽑아 카로셀 하나로 붙이는 방식을 거쳐, 지금은 + **갈래를 가리지 않고 전체에서 5~6개만 무작위로** 뽑는다(2026-09-14 대표: "34개중 + 15~19개인데 왜 34개라고 써놔?" → "그냥 5-6개 정도만 보여주는거 어때?" → "랜덤으로"). + 이 파일은 후보 전체를 만들 뿐이고, 추리고 섞고 세우는 일은 화면 쪽 책임이다. + +★ **꼭지 수는 확인되는 사실만큼이다** — 채우려고 지어내지 않는다(레포 절대 규칙). + 인물 57 · 가요 25 처럼 이 목록도 출처를 댈 수 있는 것까지가 한계다. 2026-09-14 기준 34가지. + +★ 엑셀 52주제를 그대로 옮기지 않는다 — **이미 이 페이지에 선 것은 뺀다.** + · 인물 35주제 → `people` 57명이 이미 레일로 다 보인다 + · 대중가요 21주제 → `songs` 25곡 + · 축제 14주제 → `local.festivals` 10개 + · 진포대첩·개항·세관·부잔교·『탁류』 연재·하굿둑·새만금 → `chronicle` 연표 7건 + · 히로쓰 가옥·초원사진관·근대미술관·철길마을·우체통거리 등 → `local.attractions` 12곳 + · 이성당·짬뽕집 → `local.restaurants` 31곳 + 같은 것을 두 번 세우면 페이지만 길어지고 손님은 같은 말을 두 번 읽는다. + **남은 것**이 여기 선다 — 문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활, 2026-09-14 기준 34가지. + +★ 엑셀의 문장을 그대로 쓰지 않는다. 한 주제를 운영 슬롯 7가지로 복제해 둔 시드라 + "…를 소개한다" 로 끝나는 **우리끼리 쓰는 메모**다. 여기 문장은 손님이 읽는 말로 다시 썼다. + +★ 2026-09-14 대표: "내용이 너무 적다, 특히 2줄인 것들은 최소 3~5줄로." 두 문장짜리 + 21꼭지를 네 문장으로 늘렸다 — 새로 보탠 문장은 널리 알려진 배경 설명이고, 없는 숫자를 + 지어내지는 않는다. + +★ 2026-09-14 대표: "확인필요 없애고 링크는 네이버 링크로." 기관·위키 링크를 항목마다 + 따로 골라 달던 방식을 접고, **네이버 검색 링크 하나**로 통일했다(`naver()`). + 개별 문서 URL(`terms.naver.com/entry.naver?docId=...`)은 짐작해서 적으면 없는 문서로 + 이어질 위험이 있어(레포 절대 규칙: 없는 사실은 안 짓는다) 쓰지 않는다 — 검색 링크는 + 제목을 그대로 넘기니 항상 관련 결과로 뜬다. 이 결정과 함께 '확인/확인필요' 구분도 + 화면·데이터 양쪽에서 뺐다. + +★ 렌더러에는 이 섹션이 없다. 목업이라 **주입분(`inject.js` `startReading()`)이 그린다** — + 렌더러를 고치면 전 사이트 재굽기가 따라온다(README 2.1). 제품으로 옮길 자리는 README 1.3. + +입력: stay-payload-new.json (build_story.py 뒤) +출력: 같은 파일에 `reading` 섹션을 넣는다. 화면은 `inject.js` 가 그린다. +""" +import json +from pathlib import Path +from urllib.parse import quote + +SP = Path(__file__).parent +SRC = SP / "stay-payload-new.json" + + +def naver(title): + """개별 항목마다 실제 문서를 골라 링크하지 않는다 — 검색 링크 하나로 통일해서 + '없는 문서를 짐작해 적는' 위험을 아예 없앤다(위 파일 머리 설명).""" + query = title if "군산" in title else f"{title} 군산" + return {"name": "네이버에서 찾아보기", + "url": "https://search.naver.com/search.naver?query=" + quote(query)} + + +# 갈래 — 화면에 이 순서로 선다. +GROUPS = [ + ("문학", "이 도시를 쓴 사람과 그 자리"), + ("섬과 바다", "다리를 건너면 나오는 것"), + ("역사", "연표에 안 들어간 이야기"), + ("장소", "걸어서 또는 차로 닿는 자리"), + ("음식과 생활", "이 도시가 먹는 것"), +] + +# (갈래, 제목, 글, 연도 또는 None) +# ★ 원문을 옮기지 않는다 — 시·소설·가사의 본문은 한 줄도 싣지 않고 제목과 배경만 쓴다. +ITEMS = [ + # ── 문학 8 ──────────────────────────────────────────────────────────── + ("문학", "채만식 『탁류』", + "일제강점기 군산을 무대로 한 장편소설입니다. 쌀을 사고팔던 미두장을 중심으로, " + "가족을 지키려던 초봉이 시대의 소용돌이에 휩쓸려 무너지는 과정을 그렸습니다. " + "금강 하구의 흙탕물이라는 제목처럼, 개항장 군산의 실제 지명과 풍경 위에 이야기가 " + "펼쳐집니다.", 1937), + ("문학", "백릉 채만식", + "1902년 옥구(지금의 군산) 임피에서 태어난 소설가입니다. 호는 백릉이고, 잡지에 " + "작품을 발표하며 문단에 나서 세태를 꼬집는 풍자 소설로 이름을 알렸습니다. " + "광복 뒤에는 고향 인근에 머물다 1950년 세상을 떠났습니다.", 1902), + ("문학", "채만식문학관", + "금강 하구 옆, 작가가 나고 자란 임피 인근에 선 문학관입니다. 생애를 따라가는 " + "전시실과 『탁류』를 비롯한 작품의 여러 판본을 한자리에서 볼 수 있습니다. " + "군산이 낳은 다른 작가·시인을 소개하는 자리도 함께 있습니다.", None), + ("문학", "『탁류』라는 제목", + "금강 하류의 흙탕물을 가리키는 말이자, 그 물살에 휩쓸려 가는 사람들을 함께 " + "가리키는 이중의 제목입니다. 맑았던 강물이 하구에 이르러 흐려지듯, 소설 속 " + "인물들도 도시의 욕망 앞에서 하나씩 무너져 갑니다. 신문에 회를 나눠 연재된 " + "소설이라, 그 시절 독자들은 다음 이야기를 기다리며 읽었습니다. 제목 하나에 " + "배경과 줄거리를 함께 담은 셈이라, 채만식문학관에서도 이 제목의 뜻부터 설명을 " + "시작합니다.", None), + ("문학", "채만식의 풍자", + "웃기면서 찌르는 문장이 이 작가의 방식입니다. 『레디메이드 인생』·『태평천하』 같은 " + "다른 대표작에서도 이어지는 태도로, 식민지 도시의 부조리를 인물의 우스꽝스러운 " + "말과 행동으로 드러냅니다. 개항장 군산에서 본 것이 그 풍자의 바탕에 있습니다.", + None), + ("문학", "탁류길", + "소설 속 공간을 실제 걷는 길로 이은 군산 스탬프투어 코스입니다. 미두장이 있던 " + "자리와 째보선창, 부잔교를 차례로 지나며 소설의 배경을 몸으로 확인합니다. " + "원도심 구간이라 머뭄에서 걸어 닿습니다.", None), + ("문학", "조정래 『아리랑』과 아리랑길", + "구한말부터 광복까지, 만경강 들녘 농민들이 수탈에 맞서는 이야기를 그린 대하소설입니다. " + "군산·김제 일대가 무대라 스탬프투어에도 같은 이름의 코스가 있습니다. 여러 권에 " + "걸친 긴 이야기라, 한 도시가 아니라 호남평야 들녘 전체를 무대로 다룹니다. 아리랑길은 " + "실제 그 들녘과 마을 길을 따라 걷도록 짜여 있어, 소설이 그린 풍경을 걸음으로 " + "확인할 수 있습니다.", None), + ("문학", "군산이 낳은 시인들", + "고은 · 이병훈 · 심호택 · 문효치 · 이연주 · 주하림. 일제강점기부터 지금까지 세대를 " + # ★ "아래 카드" 가 아니다 (2026-09-14 대표 지적). '인물 열전' 은 이 꼭지와 **형제 탭**이라 + # 같은 줄 위쪽에 서 있다 — 아래로 내려가면 없다. 위치를 가리키는 말을 아예 뺀다: + # 탭 차림은 화면 폭과 탭 수에 따라 줄이 바뀌어서, 어느 방향으로도 단정하면 틀린다. + "이어 시인이 나온 도시입니다. 이름만으로는 알기 어려워, '인물 열전' 탭에서 " + "한 사람씩 생애와 활동을 봅니다.", None), + + # ── 섬과 바다 6 ─────────────────────────────────────────────────────── + ("섬과 바다", "고군산군도", + "예순 남짓한 섬이 모인 군도입니다. 새만금 방조제가 육지와 신시도를 잇고, 그 뒤로 " + "무녀도 · 선유도 · 장자도가 다리로 차례로 이어져 차로 들어갈 수 있습니다. " + "섬마다 성격이 달라 하루로는 다 못 봅니다.", None), + ("섬과 바다", "선유도", + "고군산군도의 대표 섬입니다. 명사십리라 불리는 백사장을 걷고, 망주봉 아래 해변에서 " + "스카이라인(짚라인)을 탑니다. 대장봉에 오르면 다도해로 지는 해를 한눈에 봅니다. " + "머뭄에서 차로 한 시간 남짓입니다.", None), + ("섬과 바다", "장자도", + "선유도와 다리로 이어진 작은 섬입니다. 어선이 드나드는 포구를 끼고 있고, " + "마을 뒤 대장봉으로 오르는 등산로가 여기서 시작합니다. 산길이 가팔라도 길지 " + "않아 왕복 한두 시간이면 오르내릴 수 있습니다. 정상에 서면 고군산군도의 섬들이 " + "바다 위에 늘어선 모습이 한눈에 들어옵니다.", None), + ("섬과 바다", "무녀도", + "고군산군도의 섬 하나입니다. 춤추는 무녀의 형상을 닮았다는 이야기가 이름의 유래로 " + "전해지고, 작은 해수욕장이 딸려 있습니다. 신시도에서 선유도로 넘어가는 다리 길목에 " + "있어, 고군산군도를 도는 길에 자연히 거쳐 가게 됩니다. 관광객이 몰리는 선유도보다 " + "한적해, 잠시 차를 세우고 바다를 보기에 좋습니다.", None), + ("섬과 바다", "신시도", + "새만금 방조제가 육지와 처음 맞닿는 섬입니다. 고군산군도에서 몸집이 큰 섬 가운데 " + "하나로, 대각산에 오르면 방조제와 인근 섬들이 한눈에 들어옵니다. 방조제 도로를 타고 " + "들어오면 가장 먼저 만나는 섬이라, 고군산군도 여행이 시작되는 자리이기도 합니다. " + "마을과 해변이 함께 있어, 다른 섬으로 건너가기 전에 잠깐 들러 쉬어 가기도 합니다.", + None), + ("섬과 바다", "비응항", + "새만금 방조제가 시작하는 항구입니다. 젓갈과 수산물을 파는 상가가 늘어서 있고, " + "서해 관광의 관문으로 씁니다. 어선이 실어 온 수산물이 그날그날 상가로 올라와, " + "싱싱한 해산물을 바로 사거나 맛볼 수 있습니다. 항구 너머로 방조제가 곧게 뻗어 " + "있어, 바다 쪽으로 나가기 전 들르기 좋은 자리입니다.", None), + + # ── 역사 7 (연표에 없는 것만) ───────────────────────────────────────── + ("역사", "미곡 수탈과 군산항", + "일제강점기 호남평야에서 거둔 쌀이 이 항구로 모여 일본으로 나갔습니다. 부잔교와 " + "그 옆의 대형 창고들이 그 통로였고, 쌀값을 매기던 미두장이 도시 경제의 중심에 " + "있었습니다. 『탁류』가 그 시절 풍경을 그대로 배경 삼았습니다.", None), + ("역사", "째보선창", + "내항 옆 선창입니다. 언청이 뱃사공의 사연이 이름의 유래로 전해지고, 『탁류』의 " + "인물들이 오가던 자리이기도 합니다. 지금은 배 대신 낡은 창고가 남았습니다.", + None), + ("역사", "해망굴", + "일제강점기에 뚫린 굴로, 시내와 항구를 잇습니다. 차 한 대가 겨우 지날 만큼 좁아 " + "지금은 사람이 걸어서 통과해 반대편 항구 쪽으로 나옵니다. 굴 안은 서늘하고 어둑해 " + "걸어 지나가는 동안 도시의 소리가 잠깐 끊깁니다. 반대편으로 나오면 바로 내항이 " + "보여, 원도심과 항구를 잇는 지름길로 지금도 쓰입니다.", None), + ("역사", "경암동 철길", + "1944년에 놓인 철길입니다. 인근 제지공장의 화물을 나르던 산업철도였고, 지금은 " + "열차가 다니지 않지만 선로가 집 앞 골목을 그대로 지나가는 풍경만 남았습니다. " + "선로 양옆으로 집과 가게가 바짝 붙어 있어, 열차가 다니던 시절에는 문을 열면 바로 " + "철길이었습니다. 지금은 옛 교복을 빌려 입고 사진을 찍거나 선로를 따라 걷는 자리로 " + "쓰입니다.", 1944), + ("역사", "군산항", + "1899년 개항 이후 근대 산업항으로 자란 항구입니다. 만남과 이별, 물류와 근대사가 " + "한자리에 겹치며 도시의 성격이 여기서 정해졌습니다. 개항 뒤로는 쌀과 물자가 이 " + "항구를 거쳐 오갔고, 그만큼 많은 사람이 들고 났습니다. 지금 원도심에 남은 근대 " + "건축물 대부분이 이 항구를 중심으로 들어선 것들입니다.", 1899), + ("역사", "군산 3·5 만세운동", + "1919년 3월 5일, 영명학교 학생들이 중심이 되어 원도심에서 벌인 만세운동입니다. " + "전국 3·1운동이 퍼져 가던 무렵 이 도시가 어떻게 호응했는지를 보여주는 사건입니다. " + "학생들은 미리 태극기와 독립선언서를 준비해 장날에 맞춰 거리로 나섰습니다. 지금도 " + "원도심을 걷다 보면 이 사건을 알리는 표지판과 기록을 곳곳에서 만납니다.", 1919), + ("역사", "임피역", + "옛 모습을 그대로 간직한 목조 간이역입니다. 근대 철도 건축의 흔적으로 등록문화재가 " + "됐고, 지금은 열차가 서지 않고 자리만 지킵니다. 작은 대합실과 승강장이 지어질 " + "당시 모습 그대로 남아 있어, 옛 기차역의 분위기를 눈으로 확인할 수 있습니다. " + "원도심에서 조금 떨어져 있어, 차로 이동하는 여정에 잠시 들르기 좋은 자리입니다.", + None), + + # ── 장소 10 (주변 명소 12곳에 없는 것만) ────────────────────────────── + ("장소", "군산근대역사박물관", + "해양물류의 역사와 근대 생활 풍경을 층별로 나눠 보여주는 박물관입니다. 3층부터 " + "내려오며 항구도시의 근대사를 훑고, 도시를 걷기 전에 여기서 한 번 배경을 " + "잡고 나섭니다. 전시실 안에는 당시 거리와 상점을 재현한 공간도 있어, 사진으로만 " + "보던 풍경을 실제 크기로 걸어 볼 수 있습니다. 원도심 답사의 시작점으로 삼기 좋은 " + "위치라, 관람을 마치고 바로 근처 근대문화유산으로 이어 걷습니다.", None), + ("장소", "군산 내항과 부잔교", + "조수 간만의 차가 큰 서해에서 배를 대기 위해 만든 뜬다리(부잔교)입니다. " + "물이 오르내려도 다리 자체가 함께 움직여 배가 늘 수평으로 닿습니다. " + "다리 위를 끝까지 걸어 나갔다 돌아옵니다.", 1926), + ("장소", "동국사", + "국내에 남은 몇 안 되는 일본식 사찰 건축입니다. 대웅전 지붕과 마당의 배치에서 " + "일본 사찰 양식이 그대로 드러나, 개항기 흔적을 건물로 보여주는 자리입니다. " + "지금도 실제로 예불을 올리는 절이라, 관람 중에는 조용히 둘러보는 것이 예의입니다. " + "마당의 소철과 종루까지 그 시절 배치가 크게 바뀌지 않아, 사진에 담기 좋은 자리로도 " + "알려져 있습니다.", None), + ("장소", "월명공원", + "시내와 바다를 함께 내려다보는 공원입니다. 수시탑과 문인들의 시비가 산책로를 " + "따라 서 있고, 계절마다 오르는 길의 풍경이 달라집니다. 정상 부근까지 차로 오를 " + "수 있어, 가볍게 걷고 싶은 사람과 전망만 보고 싶은 사람 모두에게 맞습니다. 밤에는 " + "시내 불빛이 내려다보여, 낮과는 다른 풍경을 봅니다.", None), + ("장소", "은파호수공원", + "호수를 두르는 산책길입니다. 겨울이면 조명을 켠 벚나무 길이 볼거리가 되고, " + "봄에는 같은 길이 벚꽃길로 바뀝니다. 저녁에 한 바퀴 돌기 좋습니다.", + None), + ("장소", "금강하굿둑", + "금강과 서해가 만나는 자리에 놓은 둑입니다. 강과 바다의 경계를 사람 손으로 그은 " + "곳이고, 둑 위로 충남 서천과 전북 군산을 잇는 길이 나 있습니다. 둑을 따라 걷거나 " + "차로 건너면서 한쪽은 강물, 다른 한쪽은 바닷물이 나뉘는 모습을 눈으로 확인할 수 " + "있습니다. 철새가 많이 모이는 자리로도 알려져 있어, 계절에 따라 다른 새를 " + "봅니다.", 1990), + ("장소", "말랭이마을", + "경사진 언덕을 따라 계단식으로 집이 들어선 마을입니다. 골목 담장마다 그림이 " + "그려져 있고, 도시재생 사업으로 되살아난 자리입니다. 계단과 골목이 좁고 가팔라, " + "걷는 속도를 늦추고 천천히 둘러보게 됩니다. 언덕 위에서는 원도심과 항구 쪽 풍경이 " + "함께 내려다보입니다.", None), + ("장소", "장미갤러리", + "근대역사문화거리 안의 전시 공간입니다. 쌀을 쌓아 두던 옛 창고 건물을 고쳐 써서, " + "건물 자체가 그 시절 물류의 흔적입니다. 높은 천장과 두꺼운 벽 같은 창고의 구조를 " + "그대로 살려, 전시 공간이면서 건물 자체도 볼거리입니다. 인근의 다른 근대 건축물과 " + "나란히 있어, 걸어서 함께 둘러보기 좋습니다.", None), + ("장소", "진포해양테마공원", + "진포대첩의 기억을 항구 옆에 펼쳐 둔 공원입니다. 퇴역한 군함과 항공기를 실제로 " + "가져다 놓아, 전시장이 아니라 야외에서 크기를 몸으로 느낄 수 있습니다. 고려 말 " + "왜구를 화포로 물리친 진포대첩의 무대가 바로 이 앞바다입니다. 아이와 함께라면 " + "직접 올라타 볼 수 있는 전시물도 있어, 역사 이야기를 몸으로 체험하게 됩니다.", + None), + ("장소", "군산예술의전당", + "지역 공연을 올리는 문화 시설입니다. 원도심 답사에서 벗어나 공연 한 편을 함께 " + "일정에 넣고 싶을 때 확인해 볼 자리입니다. 크고 작은 공연장이 함께 있어, 음악회부터 " + "연극까지 다양한 공연이 열립니다. 원도심에서 차로 이동해야 하는 거리라, 일정에 " + "여유가 있을 때 넣기 좋습니다.", None), + + # ── 음식과 생활 3 ───────────────────────────────────────────────────── + ("음식과 생활", "군산 짬뽕", + "개항 이후 이 도시에 자리 잡은 화교 공동체가 남긴 음식 문화입니다. 빈해원 · " + "국제반점처럼 오래된 중식당이 아직 그 자리를 지키고, 짬뽕 한 그릇에 항구도시의 " + "내력이 담겨 있다고들 말합니다. 항구를 오가던 뱃사람과 노동자들이 빠르고 든든하게 " + "먹을 수 있는 음식으로 자리 잡았다고 전해집니다. 가게마다 국물 색과 맛이 조금씩 " + "달라, 여러 곳을 비교해 먹어 보는 재미도 있습니다.", None), + ("음식과 생활", "박대와 군산 밥상", + "서해에서 나는 박대라는 생선을 말려 구워 냅니다. 살이 도톰하고 기름기가 적어 " + "짭조름하게 구우면 밥반찬으로 잘 맞고, 이 지역 백반집 상에 자주 오릅니다. 예전에는 " + "흔해 서민 밥상에 자주 오르던 생선이었다고 전해집니다. 말려서 굽는 조리법이라 " + "오래 두고 먹을 수 있어, 지금도 반찬이나 선물용으로 찾는 사람이 많습니다.", None), + ("음식과 생활", "이성당", + "국내에서 손꼽히게 오래된 빵집 가운데 하나입니다. 단팥빵과 야채빵이 대표 메뉴로, " + "갓 나오는 시간에 맞추지 않으면 줄을 서야 할 만큼 찾는 사람이 많습니다. 일제강점기 " + "부터 이어진 자리에서 지금까지 영업을 이어 오고 있어, 군산을 대표하는 오래된 " + "가게로 꼽힙니다. 이른 아침부터 줄이 늘어서는 날이 많아, 여유 있게 들르려면 시간을 " + "넉넉히 잡는 편이 낫습니다.", None), +] + +payload = json.loads(SRC.read_text(encoding="utf-8")) +SECTIONS = payload["theme"]["sections"] + +seen = [name for name, _note in GROUPS] +for group, *_rest in ITEMS: + assert group in seen, f"갈래에 없는 이름: {group}" + +items = [] +for group, title, body, year in ITEMS: + item = {"group": group, "title": title, "body": body, "source": naver(title)} + if year: + item["year"] = year + items.append(item) + +# 부제에 전체 개수를 내걸지 않는다 — 화면은 이 중 5~6개만 매번 무작위로 보여준다 +# (2026-09-14 대표: "34개중 15~19개인데 왜 34개라고 써놔?"). 숫자 대신 "올 때마다 +# 다른"으로 적어 실제 화면과 문구가 어긋나지 않게 한다. +env = {"kind": "reading", "version": 1, "title": "군산 읽기", + "subtitle": "문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활 — 올 때마다 다른 몇 가지", + "groups": [{"name": name, "note": note} for name, note in GROUPS], + "items": items} + +# 렌더러가 모르는 id 다 — payload 자체에는 이런 섹션이 없다. `#story` 탭 하나로 화면에 +# 세우는 일은 inject.js `startReading()` 이 한다(2026-09-14부터, 독립 섹션이 아니라 탭이다). +# 여기 payload 로 두는 이유는 ① 고치는 자리가 스크립트 하나로 모이고 ② 문서(HTML)에 값이 남기 때문이다. +existing = next((s for s in SECTIONS if s["id"] == "reading"), None) +if existing is None: + existing = {"id": "reading", "name": "군산 읽기", "enabled": False, "locked": False} + SECTIONS.insert(next(i for i, s in enumerate(SECTIONS) if s["id"] == "story") + 1, existing) +existing["data"] = json.dumps(env, ensure_ascii=False) + +SRC.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + +counts = {name: sum(1 for i in items if i["group"] == name) for name, _ in GROUPS} +print("군산 읽기 " + str(len(items)) + "가지 — " + + " · ".join(f"{k} {v}" for k, v in counts.items()) + + " (화면엔 매번 5~6개만 무작위)") diff --git a/solution/site/scripts/mockup/dump_text.py b/solution/site/scripts/mockup/dump_text.py index d4222d8..a39111e 100644 --- a/solution/site/scripts/mockup/dump_text.py +++ b/solution/site/scripts/mockup/dump_text.py @@ -89,17 +89,58 @@ for item in itin: out.append(f"| {item['name']} | {item['duration']} | {item['audience']} | {item['why']} |") out += [""] +# ── 군산 읽기 ─────────────────────────────────────────────────────────────── +reading = env("reading") +out += [f"## 6. 군산 읽기 {len(reading['items'])}가지 — `build_reading.py` `ITEMS`", "", + "한 화면에 다 펴 두는 카드. 일력(7번)과 달리 손님이 훑어 내려가며 읽는 자리다.", ""] +for group in reading["groups"]: + rows = [x for x in reading["items"] if x["group"] == group["name"]] + out += [f"### {group['name']} {len(rows)}가지 — {group['note']}", "", + "| 제목 | 글 | 연도 | 출처 |", "|---|---|---|---|"] + for x in rows: + out.append(f"| {x['title']} | {x['body']} | {x.get('year', '')} | " + f"{x['source']['name']} |") + out += [""] + +# ── 오늘의 한 장 ──────────────────────────────────────────────────────────── +daily = env("daily")["items"] +out += [f"## 7. 오늘의 한 장 {len(daily)}장 — `build_daily.py`", "", + "날마다 한 장씩 넘어가는 일력. 가요는 가요 다방을 그대로 읽고, 시·소설은 손으로 적는다.", "", + "| 날짜 | 분류 | 제목 | 글 | 확인 | 출처 |", "|---|---|---|---|---|---|"] +for x in daily: + out.append(f"| {x['monthDay']} | {x['category']} | {x['title']} | {x['body']} | " + f"{x['verified']} | {x['source']['name']} |") +out += [""] + # ── 오늘의 엽서 ────────────────────────────────────────────────────────────── cards = env("postcard")["items"] -out += [f"## 6. 오늘의 엽서 {len(cards)}장 — 원본 payload (아직 다시 안 뽑음)", ""] +out += [f"## 8. 오늘의 엽서 {len(cards)}장 — 원본 payload (아직 다시 안 뽑음)", ""] out += [f"- {c['line']} `{' '.join(c.get('hashtags', []))}`" for c in cards] + [""] # ── 머뭄이 만든 노래 ───────────────────────────────────────────────────────── own = payload["narrative"]["ownSongs"]["items"] -out += [f"## 7. 머뭄이 만든 노래 {len(own)}곡 — `patch_stay.py` `OWN_SONGS`", ""] +out += [f"## 9. 머뭄이 만든 노래 {len(own)}곡 — `patch_stay.py` `OWN_SONGS`", ""] out += [f"- {s['title']} — `{s['audioUrl']}`" for s in own] + [""] +# ── 오늘의 날씨 ────────────────────────────────────────────────────────────── +# 하늘 다섯 · 기온대 다섯. 화면에는 케이스마다 한 줄만 서고 20초마다 자루에서 다음 것이 나온다. +wx = payload["local"]["weather"] +sky, temp = wx.get("noteSets", {}), wx.get("tempNoteSets", {}) +nsky, ntemp = sum(map(len, sky.values())), sum(map(len, temp.values())) +out += [f"## 10. 오늘의 날씨 문구 {nsky + ntemp}개 — `patch_stay.py` `SKY_NOTES`·`TEMP_NOTES`", "", + "하늘 줄은 **집 안**에서 뭘 할지, 기온 줄은 **밖에서** 어디를 갈지다. 두 줄이 나란히 선다.", + f"### 하늘 {nsky}개 (`SKY_NOTES`)", ""] +for case, rows in sky.items(): + out += [f"**{case}**"] + [f"- {t}" for t in rows] + [""] +out += [f"### 기온대 {ntemp}개 (`TEMP_NOTES`)", ""] +for band, rows in temp.items(): + out += [f"**{band}**"] + [f"- {t}" for t in rows] + [""] + + path = SP / "TEXT.md" path.write_text("\n".join(out), encoding="utf-8") print(f"{path.name} 캐치프레이즈 {len(box)} · 노래 {len(songs)} · 인물 {len(people)} · " - f"정거장 문구 {len(seen)} · 테마 {len(itin)} · 엽서 {len(cards)} · 자작곡 {len(own)}") + f"정거장 문구 {len(seen)} · 테마 {len(itin)} · 군산 읽기 {len(reading['items'])} · " + f"일력 {len(daily)} · 엽서 {len(cards)} · " + f"자작곡 {len(own)} · " + f"날씨 {nsky + ntemp}") diff --git a/solution/site/scripts/mockup/inject.css b/solution/site/scripts/mockup/inject.css index fd3db87..aa3b5ce 100644 --- a/solution/site/scripts/mockup/inject.css +++ b/solution/site/scripts/mockup/inject.css @@ -256,6 +256,9 @@ 두 문장이 같은 자리에서 겹쳐 보인다. ★ 자리 흔들림: 처음 구워진 줄 높이를 min-height 로 박아 둔다(스크립트가 잰다). 문장 길이가 달라 한 줄↔두 줄이 오갈 때 히어로 아래가 튀는 것을 막는다. */ +/* 대표 문구 아래에서 도는 줄 (2026-09-11). 대표 문구보다 한 단 작고 옅게 둔다 — + 둘이 같은 크기·같은 농도면 어느 쪽이 숙소의 한 줄인지 안 읽힌다. */ +.w4d-sub { margin-top: 0.5rem; font-size: var(--fs-sm, 0.9375rem); line-height: 1.6; opacity: 0.75; } .w4d-line { display: block; } .w4d-line--out { opacity: 0; @@ -327,3 +330,295 @@ animation: none !important; } /* #weather .w4-sky { display: none; } */ + +/* + * ── 가요 다방 판꽂이 ────────────────────────────────────────────────────── + * + * ★ 되짚음 (2026-09-14 대표: "모바일 대응이 안 되어있는듯? 무슨 노래인지 모르잖아, + * 모바일에서는 hover가 없으니까"). 맞는 지적이고, **아래는 내가 만든 회귀였다.** + * 원래 렌더러(`SongsSection.tsx`)는 판 아래 곡명을 **항상** 보여준다 — + * `{song.title}`, + * 숨기는 코드가 없다. 그런데 같은 날 앞선 지적("LP판이 많아져서 보기가 이상하잖아", + * 25곡이 `flex-wrap` 으로 3~9줄이 되던 문제)에 답하며 판을 겹쳐 꽂고 **곡명을 + * hover 로만 띄우게** 고쳤다 — 데스크톱 벽은 없앴지만 손가락엔 hover 가 없으니 + * 곡명이 아예 안 보이는 화면이 됐다. 데스크톱만 보고 고친 값이다. + * ★ 다시 짠다 — **곡명은 항상 보인다. 줄은 접지 않고 가로 한 줄로만 편다.** + * 판이 25곡이든 50곡이든 줄 수는 하나고, 안 보이는 나머지는 옆으로 스크롤한다. + * 이 페이지의 다른 레일(주변 명소·인물 열전)이 이미 쓰는 것과 같은 해법이라 — + * "hover 로 감추기" 는 애초에 필요 없던 처방이었다. + * ★ 마크업을 고치지 않는다(React 가 되돌린다). `:has()` 로 판이 든 상자만 집는다. + * 곡명 `` 은 원래도 절대배치가 아니라 `block` 이라 손대지 않는다 — 화면에 + * 보이려고 위치를 옮길 필요가 없다, 그냥 숨기지만 않으면 된다. + * ★ **화살표 + 실제 카로셀 라이브러리**(2026-09-14 대표: "옆으로 갈수있는 버튼도 주고 + * slider.js 적용해줘"). `inject.js` `startSongsRail()` 이 이 판 목록을 감싸는 상자를 + * 하나 만들어 `.slider-viewport` 로 표시하고(embla-carousel 코어를 그 위에 붙인다 — + * "군산 읽기" 탭과 같은 라이브러리, `vendor/embla-carousel.umd.js`), 그 안에 원래 + * 판 목록(:has() 로 잡는 이 상자)을 그대로 넣는다. **가로 스크롤·snap 은 이제 + * `.slider-viewport` 쪽 몫이다** — 그 클래스가 이미 그 CSS 를 갖고 있어(전역 번들) + * 여기서 다시 안 만든다. 이 규칙은 **안쪽 판 줄의 레이아웃(줄바꿈 금지·간격·판 크기)만** 잡는다. + */ +#songs div:has(> button > .w4-disc-mini) { + flex-wrap: nowrap; + justify-content: flex-start; + gap: 14px; + padding: 6px 4px 14px; + /* 렌더러가 이 div 자체에 준 mt-4 를 지운다 — 지금은 이 div 가 `.slider-viewport` 안에 + 들어가 있어, 그 여백이 화살표 줄(위)이 아니라 스크롤 상자 안쪽 빈 공간이 됐다. + 간격은 화살표 줄의 mt-4(inject.js `startSongsRail`)가 대신 준다. */ + margin-top: 0; +} + +#songs button:has(> .w4-disc-mini) { + flex: 0 0 auto; + width: 68px; /* 원래 96px(`w-24`) — 한 줄로만 펴니 그만큼은 줄여도 된다 */ + transition: transform 0.16s ease; +} +#songs button:has(> .w4-disc-mini) > .w4-disc-mini { + width: 56px; + height: 56px; +} +/* 곡명 — 렌더러가 원래 그리는 그 글자다. 여기서는 줄어든 판 폭에 맞춰 여백만 좁힌다. */ +#songs button:has(> .w4-disc-mini) > .w4-disc-mini + span { + margin-top: 5px; + font-size: 10px; +} +/* 고른 판만 살짝 들어 올린다 — "뽑아 든 판" 표시. 선택 자체(테두리)는 React 가 인라인 + 스타일로 이미 그린다(`boxShadow`); 여기선 그 위에 얹는 동작만 더한다. */ +#songs button:has(> .w4-disc-mini):hover { transform: translateY(-4px); } +#songs button[aria-pressed='true']:has(> .w4-disc-mini) { transform: translateY(-6px); } + +@media (prefers-reduced-motion: reduce) { + #songs button:has(> .w4-disc-mini) { transition: none; } +} +/* `#songs .slider-viewport` 로 스코프한다 — `.slider-viewport` 를 전부 건드리면 + "군산 읽기" 카로셀과 이 페이지 다른 실제 레일까지 같이 바뀐다. */ +#songs .slider-viewport { + -webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 28px), transparent 100%); + mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 28px), transparent 100%); +} + +/* + * ── 날씨 문구 전환 ──────────────────────────────────────────────────────── + * 문장이 여러 벌이라 바뀌는 자리다(inject.js ⑦). 글자가 툭 갈리지 않게 한 번 접었다 편다. + */ +#weather [data-w4d-fade] { transition: opacity 0.3s ease; } +@media (prefers-reduced-motion: reduce) { + #weather [data-w4d-fade] { transition: none; } +} + +/* + * ── 엽서 쓰기 (inject.js ⑥) ─────────────────────────────────────────────── + * `#location` 과 `#festival` 사이의 **독립 섹션**이다(2026-09-14 대표 지시로 '오늘의 + * 엽서' 탭에서 빠져나왔다). 다른 실제 섹션과 같은 껍데기(`.shell` · `--section-space` + * 세로 여백)를 쓰고, 제목·부제도 `ItemSection` 이 쓰는 것과 같은 전역 클래스(`.h2` + * `.measure`)라 이 섹션만 따로 CSS 를 두지 않는다 — 안쪽 위젯(캔버스·폼)만 여기서 잡는다. + */ +#w4d-pm-section { + width: 100%; + padding-block: var(--section-space, 64px); + background: var(--tpl-surface, #fafafa); + color: var(--tpl-text, #1b1a15); +} +.w4d-pm-layout { + display: flex; + flex-wrap: wrap; + gap: 20px; + align-items: flex-start; +} +.w4d-pm-canvas { + /* ★ 300px 이던 것을 키웠다(2026-09-14 대표: "엽서 작아서 깨지는데?"). 실제로 그리는 + 건 1080×1080 이라 화질 문제는 아니지만, 미리보기가 작으면 안의 문구·소인이 안 읽혀 + "제대로 안 나온 것" 처럼 보인다 — 미리보기 크기를 실물 크기에 맞춘다. */ + width: 420px; + max-width: 100%; + aspect-ratio: 1 / 1; + border: 1px solid var(--tpl-border, #bcb49e); + background: var(--tpl-card, #efe7d3); +} +.w4d-pm-form { flex: 1 1 320px; min-width: 260px; } +/* 썸네일 줄 — `.slider-viewport`(뷰포트) 안에 `.slider-track`(트랙, 이 클래스 자체가 + display:flex 를 준다)이 있다. 여기서는 간격·안쪽 여백만 얹는다(2026-09-14 대표: + "이미지는 다 가져와서 그냥 카로셀로 보여주든가 하셈" — 9장 전체를 후보로 두면서 + 한 줄에 다 못 앉으니 카로셀이 됐다). */ +.w4d-pm-thumbs-viewport { margin-bottom: 12px; } +.w4d-pm-thumbs { gap: 8px; padding: 2px; } +.w4d-pm-thumb { + width: 52px; + height: 52px; + flex: 0 0 auto; + padding: 0; + overflow: hidden; + border: 2px solid transparent; + border-radius: 4px; + cursor: pointer; +} +.w4d-pm-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } +.w4d-pm-thumb[aria-pressed='true'] { border-color: var(--tpl-accent, #bf2f1b); } +.w4d-pm-text { + width: 100%; + box-sizing: border-box; + padding: 10px 12px; + font: inherit; + font-family: var(--tpl-font-body, serif); + font-size: 14px; + line-height: 1.5; + color: var(--tpl-text, #1b1a15); + background: var(--tpl-surface, #fafafa); + border: 1px solid var(--tpl-border, #bcb49e); + resize: none; /* 2026-09-14 대표: "textarea 크기 못바꾸게" — 늘리면 옆 캔버스와 높이가 어긋난다 */ +} +.w4d-pm-count { margin: 4px 0 14px; font-size: 11px; text-align: right; opacity: 0.55; } +.w4d-pm-actions { display: flex; flex-wrap: wrap; gap: 10px; } +.w4d-pm-actions button { + padding: 9px 16px; + font-size: 13px; + font-weight: 700; + border: 1px solid var(--tpl-primary, #1b1a15); + cursor: pointer; +} +.w4d-pm-share { background: var(--tpl-primary, #1b1a15); color: var(--tpl-bg, #fff); } +.w4d-pm-save { background: transparent; color: var(--tpl-text, #1b1a15); } +.w4d-pm-hint { margin: 10px 0 0; font-size: 11.5px; opacity: 0.6; } +@media (max-width: 480px) { + .w4d-pm-canvas { width: 100%; } +} + +/* + * ── 군산 읽기 (inject.js ③) ─────────────────────────────────────────────── + * 렌더러에 없는 섹션이라 카드 내용 모양은 여기서 만든다. 색·서체는 전부 템플릿 변수에서 + * 가져온다 — 값을 박으면 템플릿을 바꿀 때 이 섹션만 남의 집처럼 보인다(이 파일의 규약). + * + * ★ **카로셀 껍데기는 새로 만들지 않았다** — `.slider-viewport` · `.slider-track` 은 + * 이 사이트가 이미 쓰는 클래스다(`site/src/lib/ui/Carousel.tsx`, 컴파일된 전역 CSS에 + * 이미 있다). 여기서 정의하는 CSS 는 **카드 안 내용물**뿐이다. 점(dots)·간격도 그 컴포넌트가 + * 쓰는 Tailwind 유틸리티(`h-1.5 rounded-full bg-current opacity-70` 등)를 그대로 쓴다. + * ★ **포스트 하나씩** (2026-09-14 대표). 슬라이드 하나 = 꼭지 하나, 전체 폭을 다 쓴다 — + * 3~4개를 욱여넣었더니 카드가 좁아져 본문이 한 줄로 접혔었다. + */ +/* '군산 이야기' 탭 하나로 접혀 들어왔다(2026-09-14) — 독립 섹션의 배경·제호는 없다. + 나머지 다섯 탭 패널과 같은 폭(`.shell`)에, 위아래 여백만 얹는다. */ +#w4d-reading-panel { + padding: 26px 0 4px; + color: var(--tpl-text, #1b1a15); + font-family: var(--tpl-font-body, serif); +} +/* .w4d-read-kicker 삭제 — 2026-09-14 대표: "군산읽기만 타이틀이 없서 통일 시켜", + 커스텀 캡션 대신 다른 네 탭과 같은 h2.h2 + p.measure 로 바꿨다(inject.js startReading()). */ + +/* ── 슬라이드 폭 ── `CarouselSlide` 의 관례와 같다: 모바일은 다음 장이 살짝 걸치게(86%), + 데스크톱은 한 장이 크게 온다(560px 안팎). "다음 장이 걸쳐 보이는 것" 이 옆에 더 있다는 + 유일한 시각 신호라는 그 컴포넌트의 원칙을 그대로 따른다. */ +.w4d-read-basis { width: 86%; } +@media (min-width: 640px) { .w4d-read-basis { width: min(600px, 56%); } } + +/* ── 포스트 카드 — 슬라이드 하나 = 꼭지 하나. */ +.w4d-read-card { + height: 100%; + padding: 18px 20px 16px; + background: var(--tpl-card, #efe7d3); + border: 1px solid var(--tpl-border, #bcb49e); + border-radius: var(--tpl-radius, 0); +} +.w4d-read-page { + margin: 0 0 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.18em; + color: var(--tpl-accent, #bf2f1b); +} +.w4d-read-year { font-weight: 400; letter-spacing: 0.06em; opacity: 0.7; } +.w4d-read-card h3 { + margin: 0 0 10px; + padding-bottom: 10px; + border-bottom: 2px solid var(--tpl-primary, #1b1a15); + font-family: var(--tpl-font-heading, var(--tpl-font-body, serif)); + font-size: 21px; + font-weight: 700; + line-height: 1.3; +} +.w4d-read-body { + margin: 0; + font-size: 14.5px; + line-height: 1.8; + text-align: justify; + word-break: keep-all; + opacity: 0.88; +} +.w4d-read-src { + margin-top: 14px; + padding-top: 10px; + border-top: 1px dashed var(--tpl-border, #bcb49e); + font-size: 11.5px; + font-style: italic; + opacity: 0.6; +} +.w4d-read-src a { color: inherit; text-decoration: underline; text-underline-offset: 2px; } +/* .w4d-read-flag(확인필요 배지) 삭제 — 2026-09-14 대표: "확인필요 없애고 링크는 + 네이버 링크로", inject.js startReading() 도 같이 뺐다. */ + +@media (max-width: 640px) { + .w4d-read-card h3 { font-size: 18px; } +} + +/* ══ 사진 갤러리 쪽 넘김 (데스크톱만) ═══════════════════════════════════════ + * ★ 왜 (2026-09-14 사장님: "사진갤러리 전체 렌더링 말고 페이징 좀, 너무 많아 데스크톱에서는") + * 갤러리는 좁은 화면에서 한 장씩 미는 캐러셀이지만 넓은 화면에서는 4열 격자로 **전부** + * 깔린다(`GallerySection.tsx` — `lg:grid lg:grid-cols-4`). 사진 37장이면 열 줄이 되어 + * 페이지가 사진으로 끊긴다. + * ★ 좁은 화면은 그대로 둔다 — 거기서는 한 장이 폭을 다 쓰는 캐러셀이라 이미 한 번에 하나다. + * 그래서 숨김 규칙 전부가 `min-width: 1024px`(Tailwind `lg`) 안에 있다. + * ★ 마크업을 지우지 않고 **속성으로만** 가린다. React 가 payload 로 다시 그리면 잘라낸 + *

  • 는 돌아오지만, 그때는 주입 JS 가 속성을 다시 찍는다(inject.js ⑧). + * 그리고 사진이 DOM 에 남아 있어야 alt 문장이 문서에 남는다 — 이미지 검색이 그걸 읽는다 + * (같은 이유가 `GallerySection.tsx` 머리주석에도 있다). + */ +.w4d-gal-pager { display: none; } + +@media (min-width: 1024px) { + #gallery ul[data-w4d-gal] > li[data-w4d-gal-on='0'] { display: none; } + + .w4d-gal-pager { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: 18px; + } + .w4d-gal-pager button { + min-width: 30px; + height: 30px; + padding: 0 8px; + border: 1px solid var(--tpl-border, #bcb49e); + border-radius: 8px; + background: transparent; + color: inherit; + font-size: 12.5px; + font-variant-numeric: tabular-nums; + line-height: 1; + cursor: pointer; + transition: background-color 0.15s ease, opacity 0.15s ease; + } + .w4d-gal-pager button:hover:not(:disabled) { background: color-mix(in srgb, currentColor 8%, transparent); } + .w4d-gal-pager button:disabled { opacity: 0.3; cursor: default; } + .w4d-gal-pager button[aria-current='true'] { + background: var(--tpl-primary, #1b1a15); + border-color: var(--tpl-primary, #1b1a15); + color: var(--tpl-bg, #fff); + font-weight: 700; + } + .w4d-gal-total { margin-left: 8px; font-size: 11.5px; opacity: 0.55; } +} + +/* ── 768~1023px 에서 하단 탭바가 푸터 마지막 줄을 덮는 것 막기 ───────────────── + ★ 실측(2026-09-14 대표 스크린샷, 태블릿 폭 Edge): 푸터(SiteFooter.tsx)는 768px + 부터 아래 여백을 줄이는데(pb-28 → md:pb-14), 하단 고정 탭바(MobileTabBar.tsx)는 + 1024px 이 되기 전엔 그대로 떠 있다(lg:hidden). 그래서 768~1023px 사이에서만 + 푸터 마지막 줄이 탭바에 깔린다 — 두 컴포넌트가 서로 다른 기준(md·lg)으로 + "이제 모바일 아니다"를 판단해 생기는 폭이다. + ★ 진짜 수정은 렌더러 쪽(`solution/site/src/sections/SiteFooter.tsx`)에서 + `md:pb-14` 를 탭바와 같은 `lg:pb-14` 로 맞추는 것인데, 그건 발행 사이트 전부에 + 영향을 주는 변경이라 다시 굽고(`docker compose restart solution-prerender`) + 전부 재발행(`republish_all.py`)해야 한다 — 이 목업만 먼저 막는다. */ +@media (min-width: 768px) and (max-width: 1023.98px) { + footer { padding-bottom: 7rem !important; } +} diff --git a/solution/site/scripts/mockup/inject.js b/solution/site/scripts/mockup/inject.js index fbe5a81..4ee7ada 100644 --- a/solution/site/scripts/mockup/inject.js +++ b/solution/site/scripts/mockup/inject.js @@ -43,24 +43,59 @@ var items = catchphrases(); if (items.length === 0) return; + /* ★ 대표 문구는 고정이고, 순환은 그 **아래 줄**에서 돈다 (2026-09-11 사장님 지시: + "메인 모토는 고정으로 하고, 그 아래 계속 변동되는 모토가 돌아가는 방향"). + 예전에는 대표 문구 자리를 통째로 갈아 끼워 "히로쓰 가옥 담 너머…" 가 5초 뒤 사라졌다 — + 숙소 이름 다음으로 남아야 할 한 줄이 첫 화면에서 지워지는 셈이었다. */ var base = ((P.narrative && P.narrative.tagline) || '').trim(); var node = null; - var last = null; - /* ★ 노드를 들고 있지 않고 매번 찾는다. + /* ★ 노드를 들고 있지 않고 매번 확인한다. 이 목업은 payload 를 손으로 고친 것이라 구워진 마크업과 어긋나고, React 는 하이드레이션에 - 실패하면 #root 를 **통째로 다시 그린다** — 그때 히어로의

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

    가 새 노드로 바뀌고 그 아래 붙여 둔 + 줄은 같이 사라진다. 한 번 잡아 둔 참조로 계속 쓰면 그 순간부터 아무 일도 안 일어나면서 오류도 안 난다. */ + function findTagline() { var candidates = document.querySelectorAll('#root p'); for (var i = 0; i < candidates.length; i += 1) { - if (wanted.indexOf(candidates[i].textContent.trim()) >= 0) return candidates[i]; + if (candidates[i].textContent.trim() === base) return candidates[i]; } return null; } - if (!findNode()) return; + function ensureNode() { + if (node && node.isConnected) return node; + var tagline = findTagline(); + if (!tagline) return null; + node = document.createElement('p'); + node.id = 'w4d-sub'; + node.className = 'w4d-sub measure'; + tagline.insertAdjacentElement('afterend', node); + reserve(); + return node; + } + if (!base || !findTagline()) return; + + /* 줄 높이를 **가장 긴 문구**에 맞춰 박는다. 히어로는 아래 정렬이라 이 줄이 한 줄↔두 줄을 오가면 + 숙소 이름과 대표 문구가 통째로 위아래로 튄다. 지금 폭에서 문구를 다 재 보고 가장 높은 값을 쓴다. */ + function reserve() { + var width = node.getBoundingClientRect().width; + if (!width) return; + var probe = node.cloneNode(false); + probe.removeAttribute('id'); + probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;width:' + width + 'px'; + node.parentNode.appendChild(probe); + var max = 0; + items.forEach(function (it) { + probe.textContent = it.text; + max = Math.max(max, probe.getBoundingClientRect().height); + }); + probe.remove(); + node.style.minHeight = Math.ceil(max) + 'px'; + } + var resizeTimer = null; + window.addEventListener('resize', function () { + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(function () { if (node && node.isConnected) reserve(); }, 200); + }); var byKind = function (kind) { return items.filter(function (it) { return it.kind === kind; }); @@ -118,23 +153,22 @@ } 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); @@ -163,18 +197,11 @@ 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); + }, 1200); } /* ══ ② 헤더 안 미니 플레이어 ═══════════════════════════════════════════════ @@ -348,6 +375,7 @@ if (started && started.catch) started.catch(function () { playing = false; render(); }); playing = true; render(); + return started; // 자동재생이 막혔는지 부르는 쪽이 알아야 한다 } /* 패널은 fixed 라 헤더 밑에 손으로 붙인다 — 헤더가 sticky 라 스크롤해도 자리가 같다. */ @@ -377,6 +405,8 @@ mini.addEventListener('click', function (event) { var btn = event.target.closest('button'); if (!btn) return; + disarmAutoplay(); // 손님이 직접 잡았다 — 첫 손길 대기를 푼다 + if (btn.id === 'w4d-play') { if (playing) { stop(); render(); } else { play(); } } if (btn.id === 'w4d-toggle') { togglePanel(); } }); @@ -415,16 +445,89 @@ return true; } - /* ★ 자동재생은 두지 않는다 (2026-09-11 사장님: "그럼 자동 빼"). - 크롬·사파리는 소리 나는 재생을 사용자 조작 없이 허용하지 않는다. 그래서 "막히면 - 손님이 처음 만지는 순간 켠다" 를 넣어 봤는데, 그 리스너가 window 캡처라 - **재생 단추의 클릭을 먼저 먹었다** — 첫 클릭에 켜졌다가 단추 핸들러가 다시 꺼서 - 두 번 눌러야 켜졌다. 자동재생을 빼면 그 충돌 자체가 없어진다. - 시작 곡이 무작위인 것은 그대로다 — 손님이 재생을 누르면 그 곡부터 순서대로 돈다. */ + /* ★ 자동재생 — 들어오면 바로 튼다 (2026-09-14 대표 지시. 2026-09-11 에는 "자동 빼" 였다). + 브라우저는 소리 나는 재생을 사용자 조작 없이 막는다(정책이고, 거절된 약속으로 온다). + 그래서 두 단계다 — ① 일단 걸어 보고 ② 막히면 **손님이 처음 만지는 순간** 켠다. + ★ 첫 손길 리스너는 **플레이어 안에서 난 이벤트를 흘려보낸다.** 예전 판은 window 캡처라 + 재생 단추의 클릭을 먼저 먹었고, 단추 핸들러가 다시 꺼서 두 번 눌러야 켜졌다(2026-09-11). + 지금은 버블 단계에 걸고 `#w4d-mini`·`#w4d-panel` 안이면 그냥 돌아선다 — 그 클릭은 + 단추 핸들러의 몫이고, 그 핸들러가 대기를 푼다. + ★ 시작 곡이 무작위인 것은 그대로다. */ + /* ★ 두 벌로 나눈다. 브라우저가 소리를 풀어 주는 것은 **활성화를 주는 입력**뿐이다 + (mousedown·pointerdown/up·touchend·keydown — HTML 표준 "activation triggering input"). + mousemove·wheel·scroll 은 아무리 받아도 활성화가 안 붙는다. + 실측 2026-09-14, 킹서버 · evaluate 를 안 쓴 측정: + 가만히 / mousemove / wheel → `navigator.userActivation.isActive` 계속 false + 클릭 → true + ★ 그런데도 약한 입력을 버리지 않는다 — 크롬이 이 출처에 이미 자동재생을 허락한 + 상태(Media Engagement)면 그때는 아무 입력에서나 켜지고, 그게 제일 빠르다. + ★ 다만 **약한 입력에만 스로틀을 건다.** 예전 판은 한 벌로 묶어 1초 스로틀을 걸었는데, + 클릭 직전의 mousemove 가 그 1초를 먹어서 **정작 활성화를 주는 mousedown 이 걸러졌다** + — 눌러도 안 켜졌다(실측 2026-09-14: 클릭 뒤 activation=true 인데 play=0). */ + var STRONG = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click', + 'keydown', 'touchstart', 'touchend']; + var WEAK = ['wheel', 'scroll', 'mousemove', 'pointermove', 'touchmove']; + var GESTURES = STRONG.concat(WEAK); + + var lastWeak = 0; + + function onFirstTouch(event) { + /* ★ **코드가 쏜 이벤트는 손길이 아니다.** 대기는 한 번뿐이라, 가짜 이벤트가 먼저 + 먹으면 자동재생은 영영 안 켜진다 — 브라우저는 신뢰된 조작에만 소리를 허락하므로 + 그때 부른 play() 는 반드시 거절된다. + 실측(2026-09-14): 목업 body 에 `class="site"` 를 붙여 레일 CSS 가 되살아나자 + `tameRails()` 의 `press()` 가 레일 13개에 pointerdown 을 쏘았고(자동 넘김을 세우는 + 정상 동작이다), 로드 1.05초에 그게 전부 이 자리로 들어와 대기를 태웠다. + 그 뒤로는 손님이 아무 데나 눌러도 음악이 안 켜졌다. */ + if (!event || !event.isTrusted) return; + var node = event.target; + if (node && node.closest && node.closest('#w4d-mini, #w4d-panel')) return; + /* ★ **켜진 뒤에** 대기를 푼다. 예전에는 먼저 풀고 play() 를 불렀는데, wheel·scroll 은 + 브라우저가 "조작" 으로 쳐 주지 않아 그 자리의 play() 는 거절된다 — 손님이 그냥 + 스크롤만 한 번 해도 대기가 타 버리고, 그 뒤로는 어디를 눌러도 안 켜졌다. + 지금은 거절되면 대기를 그대로 두고 다음 손길을 기다린다. */ + if (WEAK.indexOf(event.type) >= 0) { + // 약한 입력은 초당 수십 번 온다. 거절될 것을 그 횟수만큼 부르면 화면이 깜빡인다. + var now = Date.now(); + if (now - lastWeak < 1000) return; + lastWeak = now; + } + var started = play(); + if (started && started.then) started.then(disarmAutoplay, function () { /* 다음 손길에 다시 */ }); + else disarmAutoplay(); + } + + function disarmAutoplay() { + GESTURES.forEach(function (name) { window.removeEventListener(name, onFirstTouch); }); + } + + function armAutoplay() { + GESTURES.forEach(function (name) { + window.addEventListener(name, onFirstTouch, {passive: true}); + }); + } + + function autoplay() { + var started = play(); + if (started && started.then) { + started.then(disarmAutoplay, armAutoplay); + } else { + armAutoplay(); // 약속을 안 주는 브라우저 — 켜졌는지 알 수 없으니 대기도 같이 건다 + } + /* 탭을 다시 열 때 한 번 더 걸어 본다. 다른 탭에서 열어 둔 페이지는 브라우저가 + 소리를 아예 막아 두는데, 앞으로 나오는 그 순간은 조작으로 쳐 주는 경우가 있다. + 이미 켜져 있으면 아무 일도 안 한다. */ + document.addEventListener('visibilitychange', function () { + if (document.visibilityState !== 'visible' || playing) return; + var again = play(); + if (again && again.then) again.then(disarmAutoplay, function () { /* 계속 대기 */ }); + }); + } buildList(); mount(); render(); + autoplay(); if (window.MutationObserver) { new MutationObserver(function () { if (!mini.isConnected) { mount(); render(); } @@ -434,7 +537,459 @@ } - /* ══ ③ 링크 걸기 ═══════════════════════════════════════════════════════════ + /* ══ ③ 군산 읽기 ═══════════════════════════════════════════════════════════ + * 받은 자료(군산 365 이야기 DB)의 주제를 갈래로 묶어 보여준다. + * + * ★ 왜 주입인가 — 렌더러에 이 섹션이 없다. 렌더러를 고치면 번들 해시가 바뀌어 전 사이트 + * 재굽기 + 전체 재업로드가 따라온다(README 2.1). 목업에만 필요한 것은 여기서 덮는다. + * ★ **독립 섹션이 아니라 '군산 이야기'(`#story`) 탭 하나다** (2026-09-14 대표: "굳이 + * 나눌 필요가 없는것 같고" — 맞는 말이다. 가요·인물·연표·엽서·일력이 이미 그 탭 묶음에 + * 있는데 이것만 FAQ 앞에 따로 서 있을 이유가 없다). `#story` 의 tablist 에 버튼을 하나 + * 더 꽂고, 그 옆에 패널을 하나 더 붙인다 — 나머지 다섯 개와 같은 자리, 같은 모양이다. + * ★ **탭은 React 상태로 도는데 이 탭만 아니다.** React 의 `active` 는 그대로 두고 + * (React 가 모르는 탭이라 못 건드린다), 내 탭을 누르면 **다섯 개를 전부 손으로 숨기고** + * 내 것만 보인다. 반대로 진짜 탭(다섯 개 중 하나)을 누르면 **내 패널만 손으로 숨긴다** + * — React 는 자기 다섯 개를 알아서 정리하니 그건 안 건드린다. + * ★ 소개 문장("이 도시를 다섯 갈래로 봅니다")의 숫자도 여섯으로 고쳐 쓴다 — 탭 개수를 + * 문장에 안 박는 게 이 컴포넌트의 원칙인데(`COUNT_WORD`), 그 문장이 세는 `tabs.length` + * 가 React 상태라 내 탭을 모른다. 안 고치면 "다섯 갈래"라 해 놓고 탭이 여섯 개인 + * 꼴이 난다 — `daily` 가 한동안 빈칸이던 것과 같은 종류의 조용한 어긋남이다. + * ★ 처음엔 31꼭지를 한 화면에 전부 폈다. 그런데 **너무 길어 스크롤 압박**이 됐다 + * (2026-09-14 대표: "내용들이 여러개 안해도되고 3-4개 정도 그룹묶어서 랜덤하게 보여주게"). + * → 갈래마다 3~4개씩 뽑아 갈래 순서대로 잇는 방식을 거쳐, 지금은 **갈래를 가리지 + * 않고 전체 34가지 중 5~6개만 무작위로** 뽑는다(2026-09-14 대표: "34개중 15~19개인데 + * 왜 34개라고 써놔?" → "그냥 5-6개 정도만 보여주는거 어때?" → "랜덤으로"). 나머지는 + * 다음에 들어왔을 때 나온다 — 접은 게 아니라 돌려 보여주는 것이라 `

    ` 를 쓰지 + * 않는다(문서에 다 남겨야 하는 FAQ·일력과는 다른 경우다). payload 의 `subtitle` 도 + * 전체 개수를 내걸지 않게 고쳤다(`build_reading.py`) — 화면과 문구가 어긋나면 안 된다. + * → **방문할 때마다 다시 섞는다**(빌드타임에 한 번 고정하지 않는다). 매 페이지 로드가 + * 새 셔플이라 손님마다, 새로고침마다 다른 조합을 본다. + * ★ React 가 #root 를 다시 그리면 사라진다 — 감시해서 다시 꽂는다(플레이어와 같은 수법). + * 단, 무작위 조합은 **첫 마운트 때 한 번만** 뽑는다 — 재마운트마다 다시 뽑으면 하이드레이션 + * 재렌더가 잦은 페이지에서 손님이 읽던 카드가 눈앞에서 바뀐다. + */ + function shuffled(list) { + var copy = list.slice(); + for (var i = copy.length - 1; i > 0; i -= 1) { + var j = Math.floor(Math.random() * (i + 1)); + var tmp = copy[i]; copy[i] = copy[j]; copy[j] = tmp; + } + return copy; + } + function readingData() { + var sections = (P.theme && P.theme.sections) || []; + for (var i = 0; i < sections.length; i += 1) { + if (sections[i].id !== 'reading') continue; + try { + return JSON.parse(sections[i].data); + } catch (err) { + return null; + } + } + return null; + } + + function escapeHtml(text) { + return String(text == null ? '' : text) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"'); + } + + /* ★ 카로셀 껍데기는 새로 만들지 않는다 — **이 사이트가 이미 쓰는 것**을 그대로 가져다 쓴다 + (2026-09-14 대표: "기존 카로셀 쓰라고, 니가 뭔데 니맘대로 그지같이 구현해"— 맞는 말이다). + `site/src/lib/ui/Carousel.tsx` 가 만드는 마크업·클래스를 손으로 그대로 찍어낸다. + ★ `.slider-viewport`(overflow-x:auto + scroll-snap) · `.slider-track`(flex) 는 + React 컴포넌트 전용이 아니라 **번들에 이미 컴파일된 전역 CSS**다 — 이 페이지의 다른 + 레일(인물 열전 등)이 지금 쓰고 있는 그 클래스라 새 CSS 를 안 써도 그대로 먹는다. + embla 는 못 붙인다(inject.js 는 React 번들 밖의 순정 스크립트라 embla 인스턴스를 + 가져다 쓸 수 없다) — 그런데 Carousel.tsx 자신도 "**스크립트 없이도 내용이 보여야 + 한다**" 며 embla 가 붙기 전 기본 상태를 이 스크롤+snap 상자로 정의해 뒀다. 즉 지금 + 이 카드가 보이는 모양은 실제 레일의 **무-스크립트 상태와 정확히 같다.** + ★ 점(dots)·화살표도 Carousel.tsx 의 클래스를 그대로 옮긴다(`h-1.5 rounded-full + bg-current opacity-70/20`, `tap border-line rounded-full border` 등) — 전부 Tailwind + 유틸리티라 전역에 이미 있다. inject.css 에 카로셀용 CSS를 새로 안 만드는 이유다. */ + /* '군산 이야기' 탭 버튼의 on/off 인라인 스타일 — StorySection.tsx 의 값 그대로 + (`common.tsx` ITEM_INK · ITEM_INVERSE_INK · ITEM_BORDER). React 가 다른 다섯 탭에 + 주는 것과 같은 색이라야 여섯 번째가 "끼어든 것"처럼 안 보인다. */ + var STORY_TAB_ON = 'background-color:var(--tpl-text, #09090b);' + + 'color:var(--tpl-bg, #ffffff);border-color:var(--tpl-text, #09090b)'; + var STORY_TAB_OFF = 'border-color:var(--tpl-border, #d6d3d1)'; + + // lucide 의 chevron-left/right 와 같은 path 다 — NavButton(Carousel.tsx)이 쓰는 그 아이콘. + // 군산 읽기·가요 다방 화살표가 같이 쓰므로 함수 밖(모듈 스코프)에 한 벌만 둔다. + var CHEVRON_L = ''; + var CHEVRON_R = ''; + var NAV_BTN = 'tap border-line flex items-center justify-center rounded-full border ' + + 'transition-opacity hover:opacity-70 disabled:opacity-25'; + + function startReading() { + var env = readingData(); + if (!env || !env.items || !env.items.length) return; + + /* ★ **포스트 하나씩만** (2026-09-14 대표: "포스트 하나씩만 보여주라고, 한 줄인거 + 나랑 장난하자는거?"). 3~4개를 한 슬라이드에 욱여넣으면 카드 하나가 좁아져 본문이 + 한 줄로 접혔다. 지금은 슬라이드 하나 = 꼭지 하나, 전체 폭을 다 쓴다. + ★ '~면' 이라고는 안 쓴다(2026-09-14 대표: "문학면? 말이 안 되는거 같네") — 신문 + 마스트헤드를 뺀 지금은 '면'(신문 지면)이라는 틀이 없다. 그냥 갈래 이름(문학·역사·…)만 + 카드 위에 이름표로 붙인다. + ★ **갈래를 가리지 않고 전체에서 5~6개만** (2026-09-14 대표: "34개중 15~19개인데 왜 + 34개라고 써놔?" → "그냥 5-6개 정도만 보여주는거 어때?" → "랜덤으로"). 갈래마다 + 3~4개씩 뽑던 전에는 한 번에 15~19개가 떴는데, payload 의 subtitle 은 "34가지"라고 + 적혀 있어 화면과 문구가 어긋났다. 지금은 34가지를 갈래 구분 없이 통째로 섞어 5개 + 또는 6개만 뽑는다. */ + var count = Math.random() < 0.5 ? 5 : 6; + var picked = shuffled(env.items).slice(0, count).map(function (item) { + return {group: item.group, item: item}; + }); + if (!picked.length) return; + + var trackId = 'w4d-read-track'; + /* ★ 제목을 단다 (2026-09-14 대표: "군산읽기만 타이틀이 없서 통일 시켜"). 탭 버튼 + 라벨과 겹쳐 보여서 처음엔 뺐는데, 정작 실제 다섯 탭(가요 다방·인물 열전 등)은 + 전부 안에도

    를 그대로 세워 둔다(`StorySection.tsx`: "탭 이름과 겹쳐 보이지만 + …읽는 기계에게는 그 제목이 다섯 덩이를 가르는 유일한 표지다") — 나만 빼서 되레 + 튀었다. 같은 마크업(`header.mb-8.sm:mb-10 > h2.h2 + p.measure`)으로 맞춘다. */ + var html = '
    ' + + '

    ' + escapeHtml(env.title || '군산 읽기') + '

    ' + + '

    ' + escapeHtml(env.subtitle) + '

    ' + + '
    '; + + /* ↓ 여기부터 Carousel.tsx 의 실제 출력(화살표 header · slider-viewport/track · 점 · 카운터)을 + 그대로 옮긴 것이다 — 클래스 이름 하나까지 같다(파일 머리 주석 참고). */ + html += '
    ' + + '' + + '' + + '
    '; + html += '
    ' + + '
    ' + + '
    '; + picked.forEach(function (row, index) { + var item = row.item; + var source = item.source || {}; + // 출처는 항목마다 단다 — 개별 문서 대신 네이버 검색 링크로 통일했다(2026-09-14 + // 대표: "확인필요 없애고 링크는 네이버 링크로", `build_reading.py` `naver()`). + var credit = source.url + ? '' + + escapeHtml(source.name) + '' + : escapeHtml(source.name || ''); + html += '
    ' + + '
    ' + + '

    ' + escapeHtml(row.group) + + (item.year ? ' · ' + escapeHtml(item.year) + '' : '') + '

    ' + + '

    ' + escapeHtml(item.title) + '

    ' + + '

    ' + escapeHtml(item.body) + '

    ' + + '

    출처 · ' + credit + '

    '; + }); + html += '
    '; + + html += '
    '; + if (picked.length <= 12) { + html += '
      '; + picked.forEach(function (_row, index) { + html += '
    • '; + }); + html += '
    '; + } + html += '' + + '1 / ' + picked.length + '
    '; + + var panel = document.createElement('div'); + panel.id = 'w4d-reading-panel'; + panel.setAttribute('aria-labelledby', 'w4d-reading-heading'); + panel.hidden = true; // 처음엔 다른 탭(가요 다방 등)이 보인다 — React 의 기본 active 를 안 뺏는다 + // `.shell` 은 이 사이트가 폭·좌우 여백을 맞출 때 쓰는 그 전역 클래스다 — 바로 위 + // `#story .shell` 과 같은 너비라 탭이 바뀌어도 좌우 선이 흔들리지 않는다. + panel.innerHTML = '
    ' + html + '
    '; + + var tabBtn = document.createElement('button'); + tabBtn.type = 'button'; + tabBtn.setAttribute('role', 'tab'); + tabBtn.setAttribute('aria-selected', 'false'); + tabBtn.setAttribute('aria-controls', 'w4d-reading-panel'); + tabBtn.className = 'rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold ' + + 'transition-opacity hover:opacity-80'; + tabBtn.style.cssText = STORY_TAB_OFF; + tabBtn.textContent = '군산 읽기'; + + /* 자리 — '군산 이야기' 탭 묶음 **안**이다. `#story` 다음에 진짜 탭 패널 다섯 개가 + 형제로 이어지고(각자 안에 #songs·#daily·#people·#chronicle·#postcard 를 하나씩 + 담는다), 그 마지막 뒤에 내 패널을 하나 더 잇는다 — FAQ 앞이라는 점은 그대로다. */ + var KNOWN_TABS = ['songs', 'daily', 'people', 'chronicle', 'postcard']; + function mount() { + var tablist = document.querySelector('#story [role="tablist"]'); + var story = document.getElementById('story'); + if (!tablist || !story || !story.parentNode) return false; + if (!tabBtn.isConnected) tablist.appendChild(tabBtn); + + if (!panel.isConnected) { + var anchor = story; + var sib = story.nextElementSibling; + while (sib && KNOWN_TABS.some(function (id) { return sib.querySelector('#' + id); })) { + anchor = sib; + sib = sib.nextElementSibling; + } + anchor.parentNode.insertBefore(panel, anchor.nextElementSibling); + } + + // "이 도시를 다섯 갈래로 봅니다" — 진짜 탭 개수(React state)는 내 탭을 모르니 + // +1 을 손으로 반영한다. 매 재마운트마다 다시 검사해도 이미 고쳤으면 그대로 둔다. + var countWord = {1: '한', 2: '두', 3: '세', 4: '네', 5: '다섯', 6: '여섯', + 7: '일곱', 8: '여덟', 9: '아홉', 10: '열'}; + var real = tablist.querySelectorAll('[role="tab"]').length - 1; // 방금 내가 하나 더했다 + var subtitle = story.querySelector('p.measure'); + if (subtitle && real > 0) { + var word = countWord[real + 1] || String(real + 1); + subtitle.textContent = subtitle.textContent.replace( + /^이 도시를 .+ 갈래로 봅니다/, '이 도시를 ' + word + ' 갈래로 봅니다'); + } + return true; + } + if (!mount()) return; + + /* ── 탭 전환 ──────────────────────────────────────────────────────────── + * ★ 버그였던 자리 (2026-09-14 대표 제보: "시간의 골목 누르면 사라지기만 함, 탭활성화도"). + * `hidden` 은 React 가 `active` 상태로 매번 다시 계산해 주는 prop(`hidden={index!==active}`) + * 이라, 내가 여기서 그 값을 직접 true 로 덮으면 React 의 실제 상태와 DOM 이 어긋난다. + * 그 상태로 손님이 **같은 탭을 다시 누르면** `setActive(sameIndex)` 는 React 입장에서 + * 상태가 안 바뀐 것이라 리렌더를 생략한다(bail-out) — `hidden` 은 내가 true 로 덮은 채 + * 영영 안 돌아온다. 버튼 스타일(`aria-selected`·style)도 React 가 매 렌더 계산해 주는 + * 값이라 같은 이유로 OFF 에 갇힌다. → **React 가 관리하는 속성은 아예 안 건드린다.** + * 대신 내가 전적으로 소유한 `style.display` 로만 가리고(그 프롭은 JSX 에 없어 React 가 + * 절대 안 건드린다), 되돌아올 때는 그 오버라이드만 지운다 — `hidden` 은 그동안 한 번도 + * 틀린 적이 없으니 지우자마자 바로 맞는 것이 보인다. 버튼도 "방금 누른 그 탭"을 직접 + * 켜 준다 — React 리렌더가 생략돼도 항상 맞게. + */ + function selectMine() { + var tablist = document.querySelector('#story [role="tablist"]'); + if (tablist) { + Array.prototype.forEach.call(tablist.querySelectorAll('[role="tab"]'), function (btn) { + if (btn === tabBtn) return; + btn.setAttribute('aria-selected', 'false'); + btn.style.cssText = STORY_TAB_OFF; + }); + } + var story = document.getElementById('story'); + var sib = story && story.nextElementSibling; + while (sib && sib !== panel) { + if (KNOWN_TABS.some(function (id) { return sib.querySelector('#' + id); })) sib.style.display = 'none'; + sib = sib.nextElementSibling; + } + tabBtn.setAttribute('aria-selected', 'true'); + tabBtn.style.cssText = STORY_TAB_ON; + panel.hidden = false; + } + tabBtn.addEventListener('click', selectMine); + document.addEventListener('click', function (event) { + var real = event.target.closest && event.target.closest('#story [role="tab"]'); + if (!real || real === tabBtn) return; + panel.hidden = true; + tabBtn.setAttribute('aria-selected', 'false'); + tabBtn.style.cssText = STORY_TAB_OFF; + // 방금 누른 진짜 탭을 직접 켠다 — 같은 탭을 다시 눌러 React 리렌더가 생략돼도 맞다. + real.setAttribute('aria-selected', 'true'); + real.style.cssText = STORY_TAB_ON; + var story = document.getElementById('story'); + var sib = story && story.nextElementSibling; + while (sib && sib !== panel) { + if (KNOWN_TABS.some(function (id) { return sib.querySelector('#' + id); })) sib.style.display = ''; + sib = sib.nextElementSibling; + } + }); + + if (window.MutationObserver) { + /* ★ 무작위 조합은 여기서 다시 뽑지 않는다 — panel 은 이미 innerHTML 이 채워진 노드라, + 재마운트는 **같은 노드를 그대로 다시 꽂는 것**뿐이다(플레이어 패널과 같은 수법). + React 가 다시 그려도 손님이 보던 조합·지금 켜져 있던 탭은 그대로 남는다. */ + new MutationObserver(function () { + if (!tabBtn.isConnected || !panel.isConnected) mount(); + }).observe(document.getElementById('root') || document.body, {childList: true, subtree: true}); + } + + /* ── 카로셀 넘기기 ────────────────────────────────────────────────────── + * ★ **embla-carousel 코어**(파일 머리 주석)로 움직인다 — 이 사이트의 다른 레일과 + * 같은 라이브러리, 같은 드래그 물리다. `align:'center'` 라 가운데 한 장이 크게 + * 오고 양옆이 살짝 걸친다(2026-09-14 대표 지시). + * ★ **스크립트 없이도 봐야 한다**(Carousel.tsx 의 원칙과 같다) — embla 를 못 붙이면 + * `.slider-viewport` 는 `overflow-x:auto` 기본 상태 그대로 남아 손가락·트랙패드로는 + * 여전히 밀린다. 그때는 점·화살표를 `scrollIntoView` 로 대신 움직인다. + */ + var viewport = panel.querySelector('.slider-viewport'); + var track = document.getElementById(trackId); + var slideEls = Array.prototype.slice.call(panel.querySelectorAll('[id^="w4d-read-slide-"]')); + var dots = Array.prototype.slice.call(panel.querySelectorAll('.w4d-read-dot')); + var counter = panel.querySelector('.w4d-read-counter [data-cur]'); + var prevBtn = panel.querySelector('.w4d-read-prev'); + var nextBtn = panel.querySelector('.w4d-read-next'); + if (!viewport || !track || !slideEls.length) return; + + function paint(active, canPrev, canNext) { + dots.forEach(function (dot, index) { + var on = index === active; + dot.setAttribute('aria-current', on ? 'true' : 'false'); + dot.className = 'w4d-read-dot h-1.5 rounded-full transition-all ' + + (on ? 'w-6 bg-current opacity-70' : 'w-1.5 bg-current opacity-20 hover:opacity-45'); + }); + if (counter) counter.textContent = String(active + 1); + if (prevBtn) prevBtn.disabled = !canPrev; + if (nextBtn) nextBtn.disabled = !canNext; + } + + var emblaApi = null; + if (window.EmblaCarousel) { + emblaApi = window.EmblaCarousel(viewport, {align: 'center', loop: false, containScroll: 'trimSnaps'}); + viewport.setAttribute('data-slider', 'on'); // CSS 가 여기부터 스크롤 상자를 드래그 영역으로 바꾼다 + var sync = function () { + paint(emblaApi.selectedScrollSnap(), emblaApi.canScrollPrev(), emblaApi.canScrollNext()); + }; + emblaApi.on('select', sync); + emblaApi.on('reInit', sync); + // 드래그 중 텍스트가 잡히면 카드가 안 끌린다 — 커서도 grabbing 으로 바꾼다(Carousel.tsx 와 동일). + emblaApi.on('pointerDown', function () { viewport.setAttribute('data-dragging', 'true'); }); + emblaApi.on('pointerUp', function () { viewport.removeAttribute('data-dragging'); }); + dots.forEach(function (dot) { + dot.addEventListener('click', function () { emblaApi.scrollTo(Number(dot.dataset.index)); }); + }); + if (prevBtn) prevBtn.addEventListener('click', function () { emblaApi.scrollPrev(); }); + if (nextBtn) nextBtn.addEventListener('click', function () { emblaApi.scrollNext(); }); + sync(); + } else { + // embla 스크립트가 막혔거나 못 받아 왔을 때 — 가로 스크롤 자체는 CSS 만으로 이미 된다. + var goTo = function (index) { + slideEls[index].scrollIntoView({behavior: 'smooth', inline: 'center', block: 'nearest'}); + }; + dots.forEach(function (dot) { + dot.addEventListener('click', function () { goTo(Number(dot.dataset.index)); }); + }); + var findActive = function () { + var mid = viewport.scrollLeft + viewport.clientWidth / 2; + var active = 0; + for (var i = 0; i < slideEls.length; i += 1) { + if (slideEls[i].offsetLeft <= mid) active = i; + } + return active; + }; + if (prevBtn) prevBtn.addEventListener('click', function () { goTo(Math.max(0, findActive() - 1)); }); + if (nextBtn) nextBtn.addEventListener('click', function () { goTo(Math.min(slideEls.length - 1, findActive() + 1)); }); + var syncTimer = null; + viewport.addEventListener('scroll', function () { + window.clearTimeout(syncTimer); + syncTimer = window.setTimeout(function () { + var active = findActive(); + paint(active, active > 0, active < slideEls.length - 1); + }, 100); + }, {passive: true}); + paint(0, false, slideEls.length > 1); + } + + console.log('[w4d] 군산 읽기 ' + picked.length + '꼭지 카로셀(' + + (emblaApi ? 'embla' : 'CSS scroll-snap') + ') · 매번 무작위 재구성'); + } + + /* ══ ③-b 가요 다방 판꽂이 — 화살표 + 실제 카로셀 ═══════════════════════════════ + * (2026-09-14 대표: "옆으로 갈수있는 버튼도 주고 slider.js 적용해줘") + * + * ★ 렌더러는 이 판 목록을 `Carousel.tsx` 로 감싸지 않는다 — 그냥 + * `
    {판들}
    ` 이다(`SongsSection.tsx`). + * 그래서 "군산 읽기" 처럼 이미 있는 `.slider-viewport` 를 마크업에서 그대로 못 쓰고, + * **이 상자 하나를 감싸는 바깥 상자를 새로 만들어야** embla 를 붙일 자리(viewport)가 + * 생긴다 — embla 는 "바깥(viewport) 안에 트랙 하나, 그 트랙 안에 슬라이드들"이라는 + * 2단 구조를 기대한다. + * ★ **판 버튼들은 옮기지 않는다.** 그 버튼들을 담은 상자(`track`, `:has()` 로 잡는 그 + * div) 하나만 새 `viewport` 안으로 옮긴다 — 버튼 25개를 하나씩 옮기는 게 아니라 + * 부모 상자 하나만 옮기는 것이라 위험이 작다. + * ★ **React 참조는 안 깨진다.** `setPlaying` 으로 판을 고르면 React 는 이 `track` 노드를 + * (같은 자리, 같은 key 구조라) 다시 만들지 않고 속성만 고친다 — 그 노드를 누가 + * 감쌌는지는 React 가 안 본다. 그래서 옮겨 놓은 뒤에도 클릭이 계속 잘 먹는다. + * 전체가 통째로 다시 그려지는 경우(하이드레이션 재렌더)만 대비해 감시해 다시 감싼다. + * ★ 스크롤/스냅 CSS 를 내가 새로 안 만든다 — `.slider-viewport` 클래스 자체가 이미 + * 그 스타일과 `[data-slider='on']` 전환까지 갖고 있다(전역 번들, "군산 읽기" 와 같은 + * 근거). embla 가 못 붙어도 이 클래스 하나로 손가락 스크롤은 이미 된다. + */ + function startSongsRail() { + var track = document.querySelector('#songs div:has(> button > .w4-disc-mini)'); + if (!track) return false; + if (track.parentNode && track.parentNode.classList.contains('slider-viewport')) return true; + + var viewport = document.createElement('div'); + viewport.className = 'slider-viewport'; + viewport.setAttribute('role', 'group'); + viewport.setAttribute('aria-roledescription', '캐러셀'); + viewport.setAttribute('aria-label', '가요 다방 판 고르기'); + viewport.tabIndex = 0; + track.parentNode.insertBefore(viewport, track); + viewport.appendChild(track); + + var nav = document.createElement('div'); + // mt-4 — 위 검은 턴테이블 카드와 거리를 준다. 원래 이 자리(판 목록 div) 자체가 + // mt-4 였는데, 그 div 를 한 단 더 감싸면서(viewport) 그 여백이 안쪽으로 들어가 + // 버렸다 — 화살표 줄이 카드에 바로 붙어 보인 이유(2026-09-14 대표 지적). + nav.className = 'mt-4 mb-3 flex justify-end gap-2'; + nav.innerHTML = + '' + + ''; + viewport.parentNode.insertBefore(nav, viewport); + + // 카운터 — Carousel.tsx 도 점(dots)이 12개를 넘으면 점은 숨기고 이 숫자 줄만 남긴다. + // 여기는 25~50곡이라 항상 그 경우다. + var counter = document.createElement('p'); + counter.className = 'mt-2 text-center text-current/55 text-[length:var(--fs-xs)] tabular-nums'; + counter.innerHTML = '1 / ' + track.children.length; + viewport.parentNode.insertBefore(counter, viewport.nextSibling); + + var prevBtn = nav.querySelector('.w4d-songs-prev'); + var nextBtn = nav.querySelector('.w4d-songs-next'); + var curEl = counter.querySelector('[data-cur]'); + + if (window.EmblaCarousel) { + // align:'start' — Carousel.tsx 의 규칙과 같다("사진은 가운데, 카드 목록은 왼쪽"). + // 판 목록은 사진이 아니라 고르는 목록이라 왼쪽 정렬이다. + var api = window.EmblaCarousel(viewport, {align: 'start', loop: false, containScroll: 'trimSnaps'}); + viewport.setAttribute('data-slider', 'on'); + var sync = function () { + curEl.textContent = String(api.selectedScrollSnap() + 1); + prevBtn.disabled = !api.canScrollPrev(); + nextBtn.disabled = !api.canScrollNext(); + }; + api.on('select', sync); + api.on('reInit', sync); + api.on('pointerDown', function () { viewport.setAttribute('data-dragging', 'true'); }); + api.on('pointerUp', function () { viewport.removeAttribute('data-dragging'); }); + prevBtn.addEventListener('click', function () { api.scrollPrev(); }); + nextBtn.addEventListener('click', function () { api.scrollNext(); }); + sync(); + } else { + // embla 를 못 붙였을 때 — `.slider-viewport` 기본 상태가 이미 스크롤 상자라 + // 손가락·트랙패드로는 여전히 밀린다. 화살표만 판 한 장 폭만큼 밀어 대신한다. + var step = function () { + var first = track.querySelector('button'); + return (first ? first.getBoundingClientRect().width : 68) + 14; + }; + var syncFallback = function () { + prevBtn.disabled = viewport.scrollLeft <= 2; + nextBtn.disabled = viewport.scrollLeft >= viewport.scrollWidth - viewport.clientWidth - 2; + curEl.textContent = String(Math.round(viewport.scrollLeft / step()) + 1); + }; + prevBtn.addEventListener('click', function () { viewport.scrollBy({left: -step(), behavior: 'smooth'}); }); + nextBtn.addEventListener('click', function () { viewport.scrollBy({left: step(), behavior: 'smooth'}); }); + var songsSyncTimer = null; + viewport.addEventListener('scroll', function () { + window.clearTimeout(songsSyncTimer); + songsSyncTimer = window.setTimeout(syncFallback, 100); + }, {passive: true}); + syncFallback(); + } + return true; + } + + + /* ══ ④ 링크 걸기 ═══════════════════════════════════════════════════════════ * 렌더러는 일정의 "지도 검색 …" 과 엽서의 장소를 **글자로만** 낸다. 손님이 그 자리에서 * 지도를 열 방법이 없다 — 이름을 복사해 다른 앱에 붙여야 한다. * 렌더러를 고치면 다른 사이트까지 바뀌므로(목업만 손대는 규약), 여기서 DOM 을 덧칠한다. @@ -537,7 +1092,7 @@ } - /* ══ ④ 카로셀 — 손님이 만지는 동안에는 자동으로 넘기지 않는다 ══════════════════ + /* ══ ⑤ 카로셀 — 손님이 만지는 동안에는 자동으로 넘기지 않는다 ══════════════════ * * ★ 이 페이지의 카로셀은 **구현이 둘**이다. * ① embla-carousel(라이브러리) — 일정·축제·주변 안내. `.slider-viewport` 를 쓰고 @@ -689,12 +1244,721 @@ }, 250); } + /* ══ ⑥ 엽서 쓰기 — 스테이,머뭄 엽서를 직접 써서 공유 ═══════════════════════════ + * (2026-09-14 대표 지시 세 번에 걸쳐 정리됐다) + * ① "오늘의 엽서를 진짜 엽서처럼 자기가 텍스트 입력해서 공유하면 어떨까?" + * ② "오늘의 엽서 칸을 빼고 이 엽서 포맷으로 스테이 머뭄 관련 엽서를 쓰는 걸로 하자. + * 이건 축제소개 전에 섹션으로 빼줘." — 도시 엽서(옛 postcard 탭)는 **없앤다**. + * 이 기능은 더 이상 '군산 이야기' 탭 하나가 아니라 **독립 섹션**이고, 사진도 + * 군산 명소가 아니라 **이 숙소 사진**(payload.media)을 쓴다. + * ③ "이미지는 스테이머뭄 이미지 선택으로" · "엔터치면 줄바꿈 부탁" + * ④ "태그 지우셈 UI깨짐 #스테이머뭄" · "플레이스홀더도 그냥 일반적인 문구로 — + * 스테이머뭄뿐만 아니라 다른 숙박업소나 카페도 써야 하는데 이렇게 써놓으면 + * 어떡해" — **이 업소 이름을 코드에 못 박지 않는다.** 해시태그 줄(#스테이머뭄 + * #군산여행)은 통째로 뺐고, 입력창 placeholder 의 예문도 이 업소 이름이 빠진 + * 일반 문구로 바꿨다. 우표 옆 소인(원 안의 상호명)도 "스테이"·"머뭄" 고정 + * 두 글자가 아니라 `placeName` 을 받아 어느 상호든 접어 넣는 `stampLines()` + * 로 바꿨다 — 이 스크립트가 다른 업소에 그대로 옮겨져도 코드를 안 고쳐도 된다. + * + * ★ **og:image 링크 카드가 아니라 파일 그대로 공유다**(2026-09-14 대표 확정, 별도 논의). + * 이 백엔드엔 익명 업로드 엔드포인트가 없다(사진은 전부 로그인한 사장님 전용). + * Web Share API 로 캔버스 이미지를 파일째 공유 시트에 넘긴다 — 카카오톡이 그 시트에 + * 있으면 백엔드 없이도 이미지가 그대로 대화방에 올라간다. 데스크톱처럼 파일 공유를 + * 못 받는 브라우저는 "이미지로 저장" 버튼으로 대신한다(항상 같이 둔다). + * ★ **글꼴은 화면과 같은 것**(대표: "폰트 동일한걸로") — 이미 떠 있는 `.serif` 요소의 + * computed font-family 를 그대로 `ctx.font` 에 옮긴다. + * ★ **모양은 진짜 엽서 카드(`PostcardSection.tsx`)를 그대로 베낀다** — 사진(위) + + * 괘선으로 가른 두 칸(왼쪽 문구, 오른쪽 우표+소인) + 맨 아래 서명 줄. + * 새로 만든 위젯이 아니라 "이 사이트에 있던 그 엽서를 내가 쓴 것"처럼 보여야 한다. + * ★ **자리 — 독립 섹션, `#location`(오시는 길)과 `#festival`(계절별 축제) 사이**다. + * 더는 '군산 이야기' 탭이 아니라서 React 의 tab hidden 로직에 안 걸린다 — 늘 보인다. + * ★ 옛 '오늘의 엽서' 탭은 `patch_stay.py` 에서 payload 의 `postcard` 섹션 데이터를 + * 비워 없앤다(StorySection 은 `items.length > 0` 인 탭만 세운다 — data 를 비우는 것만 + * 으로 렌더러를 안 건드리고 탭이 사라진다). + */ + function postcardMaker() { + // ★ 4장으로 자르지 않는다(2026-09-14 대표: "이미지는 다 가져와서 그냥 카로셀로 + // 보여주든가 하셈") — 숙소 사진 전부(지금 9장)를 후보로 둔다. 개수가 늘면 한 줄에 + // 다 못 앉으니 아래서 썸네일 줄 자체를 카로셀(`.slider-viewport` + embla)로 만든다. + var photos = (P.media || []).filter(function (m) { return m.url; }); + if (!photos.length) return false; + if (document.getElementById('w4d-pm-section')) return true; // 이미 붙어 있다 + + var placeName = (P.place && (P.place.name || P.place.title)) || '스테이,머뭄'; + var region = (P.place && (P.place.regionName || P.place.address || '')) || '전북 군산'; + var regionShort = String(region).split(' ').slice(0, 2).join(' '); + + var section = document.createElement('section'); + section.id = 'w4d-pm-section'; + section.setAttribute('aria-labelledby', 'w4d-pm-heading'); + section.innerHTML = + '
    ' + + '
    ' + + '

    엽서 쓰기

    ' + + '

    ' + + escapeHtml(placeName) + '에서 보내는 한 장을 직접 써 봅니다. 사진을 고르고 한 마디를 적으면 ' + + '그 자리에서 엽서가 되고, 그대로 공유하거나 저장할 수 있습니다.

    ' + + '
    ' + + '
    ' + + '' + + '
    ' + + /* 썸네일 줄도 이 사이트의 진짜 카로셀 껍데기를 그대로 쓴다(가요 다방·군산 읽기와 + 같은 근거 — README `startSongsRail` 주석 참고). role=radiogroup 은 안쪽 + track 이 아니라 이 뷰포트에 둔다 — 스크린리더가 "그룹" 보다 "라디오군" 을 + 먼저 읽어야 사진 고르기라는 게 전달된다. */ + '
    ' + + '
    ' + + photos.map(function (m, i) { + return ''; + }).join('') + + '
    ' + + '' + + '

    0/60

    ' + + '
    ' + + '' + + '' + + '
    ' + + '' + + '
    '; + + function mount() { + if (section.isConnected) return true; + var festival = document.getElementById('festival'); + if (festival && festival.parentNode) { + festival.parentNode.insertBefore(section, festival); + return true; + } + var location = document.getElementById('location'); + if (location && location.parentNode) { + location.parentNode.insertBefore(section, location.nextElementSibling); + return true; + } + return false; + } + if (!mount()) return false; + + var canvas = section.querySelector('.w4d-pm-canvas'); + var ctx = canvas.getContext('2d'); + var textarea = section.querySelector('.w4d-pm-text'); + var lenEl = section.querySelector('[data-len]'); + + // draw() 안 사진 아래 레이아웃(margin·우표 칸 폭 190)과 같은 값이다 — 하나를 고치면 + // 이 상수도 같이 고친다. 여기서도 필요한 이유: 몇 줄로 접힐지 **입력 중에 미리 재서** + // MAX_LINES 를 넘기지 못하게 막는다(2026-09-14 대표: "4줄이상 못쓰게 막으라고"). + var TEXT_COL_W = canvas.width - 56 - 190 - 56 - 24; + var MAX_LINES = 4; + var thumbs = Array.prototype.slice.call(section.querySelectorAll('.w4d-pm-thumb')); + + // 썸네일 카로셀 — embla 가 있으면 붙이고, 없으면 `.slider-viewport` 기본 스크롤로 남는다 + // (가요 다방과 같은 폴백 규약). 사진을 고르는 라디오 그룹이라 화살표·점은 안 둔다 — + // 판만 보고 눌러서 고르면 되는 자리다. + var thumbsViewport = section.querySelector('.w4d-pm-thumbs-viewport'); + if (thumbsViewport && window.EmblaCarousel) { + var thumbsApi = window.EmblaCarousel(thumbsViewport, {align: 'start', loop: false, containScroll: 'trimSnaps'}); + thumbsViewport.setAttribute('data-slider', 'on'); + thumbsApi.on('pointerDown', function () { thumbsViewport.setAttribute('data-dragging', 'true'); }); + thumbsApi.on('pointerUp', function () { thumbsViewport.removeAttribute('data-dragging'); }); + } + + var shareBtn = section.querySelector('.w4d-pm-share'); + var saveBtn = section.querySelector('.w4d-pm-save'); + var hint = section.querySelector('.w4d-pm-hint'); + + /* ★ 버튼 문구에 "카카오톡" 을 못 박지 않는다 (2026-09-14 대표: "맥에서 메세지로밖에 + 안되는데..? 카카오톡 등으로 공유가 아니잖아 그럼" — 실측·정확한 지적). + `navigator.share` 가 여는 시트는 **OS 가 채운다** — iOS·Android 는 설치된 앱이 + 공유 대상으로 등록되어 카카오톡이 뜨지만, **macOS 데스크톱은 앱이 시스템 공유 + 확장(NSSharingService)에 거의 등록을 안 해서** 메시지·메모·AirDrop 정도만 뜬다. + 카카오톡 macOS 앱도 그 확장을 안 만든다 — 그래서 맥에서는 "메시지"만 보이는 게 + 버그가 아니라 OS 사양이다. 대신 **손끝으로 조작하는 기기(coarse pointer)에서만** + "카카오톡 등으로 공유" 라고 적고, 마우스로 조작하는 기기(주로 데스크톱)에서는 + 그 앱 이름을 빼고 그냥 "공유하기" 라고 적는다 — 버튼이 실제로 하는 일은 같지만 + 손님이 기대할 결과를 기기에 맞게 정직하게 알려준다. */ + var isTouch = window.matchMedia && window.matchMedia('(pointer: coarse)').matches; + shareBtn.textContent = isTouch ? '카카오톡 등으로 공유' : '공유하기'; + + var picked = 0; + var images = {}; // url → HTMLImageElement(로드 완료분) + var redrawTimer = null; + + function loadImage(url) { + if (images[url]) return Promise.resolve(images[url]); + return new Promise(function (resolve, reject) { + var img = new Image(); + img.onload = function () { images[url] = img; resolve(img); }; + img.onerror = reject; + img.src = url; + }); + } + + /* 엽서 문구용 줄바꿈. 캔버스는 CSS 처럼 자동 줄바꿈을 안 해 준다. + ★ **엔터는 그대로 줄바꿈이다**(2026-09-14 대표: "엔터치면 줄바꿈 부탁") — 먼저 + `\n` 으로 문단을 가르고, 각 문단만 폭에 맞춰 다시 접는다. `\s+` 로 한 번에 쪼개면 + 사용자가 누른 엔터가 스페이스와 같은 취급을 받아 그대로 이어 붙어 버린다. */ + function wrapLines(text, maxWidth) { + var lines = []; + text.split('\n').forEach(function (para) { + if (para.trim() === '') { lines.push(''); return; } + var words = para.split(' '); + var line = ''; + words.forEach(function (word) { + var test = line ? line + ' ' + word : word; + if (ctx.measureText(test).width <= maxWidth) { line = test; return; } + if (line) lines.push(line); + if (ctx.measureText(word).width <= maxWidth) { line = word; return; } + /* ★ 공백 없는 긴 글자열은 단어 단위로 안 접힌다 (2026-09-14 대표 제보 — 자음만 + 줄줄이 이어친 문구가 우표 칸까지 넘쳐 나갔다). `stampLines()` 와 같은 방법으로 + 글자 단위로도 접는다. */ + var chunk = ''; + for (var i = 0; i < word.length; i += 1) { + var next = chunk + word[i]; + if (ctx.measureText(next).width > maxWidth && chunk) { + lines.push(chunk); + chunk = word[i]; + } else { + chunk = next; + } + } + line = chunk; + }); + if (line) lines.push(line); + }); + return lines; + } + + /* 소인(우표 옆 회전 원) 안 상호명 줄바꿈 — `wrapLines` 와 따로 둔다. + ★ **이 업소 이름을 박아 두지 않는다**(2026-09-14 대표: "다른 숙박업소나 카페도 + 써야 하는데 이렇게 써놓으면 어떡해"). "스테이"·"머뭄" 두 글자씩 고정 두 줄이던 걸 + 빼고, **어느 상호가 와도** 원 안에 맞게 접는다. 콤마·공백처럼 상호 안의 자연스러운 + 경계에서 먼저 끊고, 그 조각 하나가 원보다 넓으면 글자 단위로도 접는다 — + "스테이,머뭄" 처럼 사이 공백 없는 이름도 있어 `wrapLines`(공백 기준)만으로는 + 한 줄로 넘쳐 버린다. */ + function stampLines(name, maxWidth) { + var parts = name.split(/[,\s]+/).filter(Boolean); + var lines = []; + var line = ''; + parts.forEach(function (part) { + var test = line ? line + ' ' + part : part; + if (ctx.measureText(test).width <= maxWidth) { + line = test; + return; + } + if (line) lines.push(line); + if (ctx.measureText(part).width <= maxWidth) { + line = part; + return; + } + var chunk = ''; + for (var i = 0; i < part.length; i += 1) { + var next = chunk + part[i]; + if (ctx.measureText(next).width > maxWidth && chunk) { + lines.push(chunk); + chunk = part[i]; + } else { + chunk = next; + } + } + line = chunk; + }); + if (line) lines.push(line); + return lines; + } + + function draw() { + var photo = photos[picked]; + var size = canvas.width; // 정사각형(1080) — 카톡 등 공유의 1:1 관례와 같다 + var serifFont = 'serif'; + var probe = document.querySelector('.serif'); + if (probe) { + var family = getComputedStyle(probe).fontFamily; + if (family) serifFont = family; + } + var ink = '#1b1a15'; + var paper = '#efe7d3'; + var accent = '#bf2f1b'; + var margin = 56; + + ctx.clearRect(0, 0, size, size); + ctx.fillStyle = paper; + ctx.fillRect(0, 0, size, size); + + loadImage(photo.url).then(function (img) { + /* ── 위: 사진 (실제 카드의 aspect-3/2 앞면과 같은 비율) ── */ + var photoX = margin, photoY = margin; + var photoW = size - margin * 2; + var photoH = photoW / 1.5; + var scale = Math.max(photoW / img.width, photoH / img.height); + var w = img.width * scale, h = img.height * scale; + ctx.save(); + ctx.beginPath(); + ctx.rect(photoX, photoY, photoW, photoH); + ctx.clip(); + ctx.drawImage(img, photoX + (photoW - w) / 2, photoY + (photoH - h) / 2, w, h); + ctx.restore(); + ctx.strokeStyle = 'rgba(27,26,21,0.25)'; + ctx.lineWidth = 1; + ctx.strokeRect(photoX + 0.5, photoY + 0.5, photoW - 1, photoH - 1); + + /* ── 아래: 괘선으로 가른 두 칸(실제 카드 grid-cols-[1fr_auto] 그대로) ── */ + var bodyY = photoY + photoH + 36; + var stampColW = 190; + var dividerX = size - margin - stampColW; + var textColW = dividerX - margin - 24; + + ctx.strokeStyle = 'rgba(27,26,21,0.35)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(dividerX, bodyY); + ctx.lineTo(dividerX, size - margin - 46); + ctx.stroke(); + + // 왼쪽 — 사용자 문구(“ ” 인용부호, 실제 카드와 같은 서체) + // ★ 해시태그 줄은 뺐다(2026-09-14 대표: "태그 지우셈 UI깨짐") — 짧은 문구 뒤에 + // 고정 해시태그(#스테이머뭄 #군산여행)를 더 얹었더니 두 줄짜리 긴 문구에서는 + // 해시태그가 우표 칸 밑으로 겹쳐 나갔다. 문구 하나만 남긴다. + var text = textarea.value.trim() || '오늘, 이 숙소에서 하루를 보냅니다.'; + ctx.fillStyle = ink; + ctx.font = '600 46px ' + serifFont; + ctx.textAlign = 'left'; + ctx.textBaseline = 'alphabetic'; + var wrapped = wrapLines('“' + text + '”', textColW); + // 입력 쪽(textarea input 핸들러)에서 이미 4줄을 못 넘게 막지만, 여기서도 한 번 더 + // 자른다 — 그래야 어떤 경로로 그려지든(붙여넣기 등) 카드가 절대 안 깨진다. + var lines = wrapped.slice(0, MAX_LINES); + if (wrapped.length > MAX_LINES) { + var last = lines[MAX_LINES - 1]; + while (last.length && ctx.measureText(last + '…').width > textColW) last = last.slice(0, -1); + lines[MAX_LINES - 1] = last + '…'; + } + var lineHeight = 60; + lines.forEach(function (line, i) { + ctx.fillText(line, margin, bodyY + 50 + i * lineHeight); + }); + + // 오른쪽 — 우표 + 소인(실제 카드의 郵票 박스·회전 원 그대로) + var stampCx = dividerX + 24 + stampColW / 2 - 24; + ctx.strokeStyle = ink; + ctx.lineWidth = 1.5; + ctx.setLineDash([4, 4]); + ctx.strokeRect(stampCx - 44, bodyY, 88, 108); + ctx.setLineDash([]); + ctx.fillStyle = ink; + ctx.font = '400 20px ' + serifFont; + ctx.textAlign = 'center'; + ctx.fillText('郵票', stampCx, bodyY + 46); + ctx.fillText('10원', stampCx, bodyY + 78); + + ctx.save(); + ctx.translate(stampCx, bodyY + 210); + ctx.rotate((-6 * Math.PI) / 180); + ctx.strokeStyle = accent; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(0, 0, 62, 0, Math.PI * 2); + ctx.stroke(); + ctx.fillStyle = accent; + ctx.font = '600 22px ' + serifFont; + // 상호가 무엇이든(숙소·카페 어디에도 이 스크립트가 그대로 쓰인다) 원 안에 + // 맞춰 접는다 — 이 업소 이름을 고정으로 박지 않는다. + var nameLines = stampLines(placeName, 100).slice(0, 2); + var nameLH = 26; + var nameStartY = 8 - ((nameLines.length - 1) * nameLH) / 2; + nameLines.forEach(function (line, i) { + ctx.fillText(line, 0, nameStartY + i * nameLH); + }); + ctx.restore(); + + // 맨 아래 — 점선 + 서명 줄(실제 카드의 출처 줄과 같은 자리) + var footY = size - margin - 30; + ctx.strokeStyle = 'rgba(27,26,21,0.3)'; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(margin, footY - 20); + ctx.lineTo(size - margin, footY - 20); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = 'rgba(27,26,21,0.6)'; + ctx.font = '400 22px ' + serifFont; + ctx.textAlign = 'left'; + ctx.fillText(placeName + ' · ' + regionShort, margin, footY + 6); + }); + } + + function scheduleDraw() { + window.clearTimeout(redrawTimer); + redrawTimer = window.setTimeout(draw, 80); + } + + thumbs.forEach(function (btn, i) { + btn.addEventListener('click', function () { + picked = i; + thumbs.forEach(function (t, j) { t.setAttribute('aria-pressed', j === i ? 'true' : 'false'); }); + scheduleDraw(); + }); + }); + // 몇 줄로 접힐지는 폭·서체에 달려 있어 글자 수로는 못 잰다 — draw() 와 같은 + // wrapLines() 로 실제 접은 결과를 재서 넘으면 되돌린다(2026-09-14 대표: "4줄이상 + // 못쓰게 막으라고"). 자음만 이어치거나 엔터를 여러 번 눌러도 여기서 막힌다. + var lastGoodText = textarea.value; + textarea.addEventListener('input', function () { + if (wrapLines('“' + textarea.value + '”', TEXT_COL_W).length > MAX_LINES) { + textarea.value = lastGoodText; + } else { + lastGoodText = textarea.value; + } + lenEl.textContent = String(textarea.value.length); + scheduleDraw(); + }); + textarea.addEventListener('keydown', function (event) { + // Enter 는 기본 동작(줄바꿈)을 그대로 둔다 — 폼 전송이 아니므로 막을 이유가 없다. + // 여기서는 눌린 즉시 다시 그려 손님이 줄바꿈 결과를 바로 본다(input 이벤트보다 빠르다). + if (event.key === 'Enter') scheduleDraw(); + }); + + function exportBlob(cb) { + canvas.toBlob(function (blob) { cb(blob); }, 'image/png', 0.95); + } + + /* ★ 사진 + 링크만 보낸다, 캡션 문구는 안 넣는다(2026-09-14 대표: "이미지+내용+링크 + 하니까 안예쁨" — 손님이 쓴 문구는 이미 canvas 안 엽서 그림에 박혀 있는데, 시트에 + 또 같은 문구를 글자로 한 번 더 얹으면 같은 말이 두 번 보였다). `files` 와 `url` + 을 같은 navigator.share() 호출에 같이 넣는 조합은 기기마다 지원이 갈려서, 파일이 + 실리는 경우엔 링크를 `text` 자리에 적어 붙인다(문구 없이 링크 한 줄만). */ + shareBtn.addEventListener('click', function () { + var urlMeta = document.querySelector('meta[property="og:url"]'); + var pageUrl = (urlMeta && urlMeta.content) || (location.origin + location.pathname); + exportBlob(function (blob) { + if (!blob) return; + var file = new File([blob], 'stay-postcard.png', {type: 'image/png'}); + if (navigator.canShare && navigator.canShare({files: [file]})) { + navigator.share({files: [file], title: placeName + ' 엽서', text: pageUrl}) + .catch(function () { /* 손님이 시트를 닫은 것도 여기로 온다 — 오류로 안 다룬다 */ }); + } else if (navigator.share) { + // 파일 공유를 못 받는 브라우저(주로 데스크톱)는 링크만이라도 시트로 보낸다. + navigator.share({title: placeName + ' 엽서', url: pageUrl}) + .catch(function () { /* 위와 같은 이유로 오류로 안 다룬다 */ }); + hint.hidden = false; + hint.textContent = '이 브라우저는 사진은 같이 못 보내 링크만 보냈습니다. 사진은 "이미지로 저장" 을 눌러 주세요.'; + } else { + hint.hidden = false; + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; a.download = 'stay-postcard.png'; a.click(); + URL.revokeObjectURL(url); + } + }); + }); + saveBtn.addEventListener('click', function () { + exportBlob(function (blob) { + if (!blob) return; + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; a.download = 'stay-postcard.png'; a.click(); + URL.revokeObjectURL(url); + }); + }); + + // 폰트가 이 시점에 아직 안 실렸을 수 있다 — 실리고 나서 한 번 더 그린다. + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(draw); + } + draw(); + return true; + } + + + /* ══ ⑦ 날씨 카드 문구 — 케이스마다 여러 벌, 랜덤 ═══════════════════════════════ + * ★ 왜 주입인가 (2026-09-11 사장님: "그외만 … 으로 바꿔줘 · 목업만이야") + * 카드는 하늘을 넷(맑음·흐림·비·눈)으로만 가르므로 **구름많음(코드 1~3)과 그 외**(안개 45 · + * 소나기 80~82 · 뇌우 95~99 …)가 payload 의 같은 칸(`local.weather.notes.흐림`)을 본다 — + * 칸이 하나라 payload 로는 둘을 못 가른다. 렌더러(`derive.ts weatherMood`)를 가르는 건 + * 제품을 건드리는 일이고, 목업은 재굽기 대상이 아니라 고쳐도 이 페이지엔 오지 않는다. + * ★ 문장을 여러 벌 돌린다 (2026-09-14 사장님: "케이스당 여러개로 만들어서 랜덤으로") + * 전에는 케이스마다 한 줄이라 새로고침해도 같은 문장이었다. 이제 하늘 5 · 기온대 5, + * 각 다섯 벌씩 `local.weather.noteSets` · `tempNoteSets` 에 있다(patch_stay.py 가 넣는다). + * **자루에서 뽑아 쓰고 비면 다시 채운다** — 매번 랜덤이면 같은 문장이 연달아 나온다 + * (캐치프레이즈와 같은 규칙). + * ★ 뽑는 것은 **들어올 때 한 번**이다. 화면에 떠 있는 동안 저절로 바뀌지 않는다 — + * 읽는 중에 문장이 갈리는 것이 손님에게는 고장으로 보인다. 10분마다 도는 날씨 갱신에서 + * 하늘·기온대가 달라졌을 때만 다시 뽑는다. + * ★ 굽힌 판에는 케이스마다 첫 문장이 박혀 있다. 그래서 DOM 에서 바꿀 줄을 **아는 문장 전부**와 + * 대조해 찾는다 — 한 번 갈아 끼운 뒤에는 원래 문장이 거기 없기 때문이다. + * ★ React 는 날씨를 10분마다 다시 받아 그리면서 payload 문장으로 되돌린다 + * (`use-live-weather`) — 옵저버로 되돌아간 것을 보고 다시 넣는다. 이미 맞으면 손대지 + * 않으므로 옵저버가 자기 변경을 다시 보는 순환은 생기지 않는다. + */ + /* 렌더러의 기온대(`site/src/lib/derive.ts weatherBand`)와 **같은 경계**여야 한다 — + 화면의 배지는 렌더러가 붙이고 문장만 여기서 갈아 끼우므로, 어긋나면 '추움' 배지 옆에 + 혹서 문장이 선다. */ + function bandOf(temperature) { + if (temperature >= 30) return '혹서'; + if (temperature >= 25) return '더움'; + if (temperature >= 20) return '선선'; + if (temperature >= 10) return '쌀쌀'; + return '추움'; + } + + /* 카드가 '흐림' 칸을 쓰는 코드 가운데 **구름많음이 아닌** 것. 렌더러의 분류에서 맑음·비·눈에 + 해당하는 구간과 1~3 을 뺀 나머지가 전부 '그외'다. */ + function skyOf(code) { + if (code === 0) return '맑음'; + if (code >= 1 && code <= 3) return '구름많음'; + if (code >= 50 && code <= 69) return '비'; + if (code >= 70 && code <= 79) return '눈'; + return '그외'; + } + + /* 구운 payload 의 조건 문자열 → 같은 다섯. 렌더러의 `derive.ts weatherMood` 와 순서를 + 맞춘다 — "비/눈" 같은 합성 표기에서 눈을 먼저 본다. */ + function skyOfText(text) { + if (/눈|설/.test(text)) return '눈'; + if (/비|우|소나기/.test(text)) return '비'; + if (/구름/.test(text)) return '구름많음'; + if (/흐림|안개|박무/.test(text)) return '그외'; + return '맑음'; + } + + /* 자루 — 한 바퀴를 다 쓰기 전에는 같은 문장이 다시 나오지 않는다. */ + function bagger(pools) { + var bags = {}; + return function next(key) { + var pool = pools[key]; + if (!pool || !pool.length) return null; + if (!bags[key] || !bags[key].length) { + bags[key] = pool.slice(); + if (bags[key].length > 1) bags[key].sort(function () { return Math.random() - 0.5; }); + } + return bags[key].shift(); + }; + } + + function startWeatherNote() { + var w = (P.local && P.local.weather) || {}; + var place = P.place || {}; + var skyPools = w.noteSets || {}; + var tempPools = w.tempNoteSets || {}; + + /* 아는 문장 전부. 굽힌 판의 값(notes·tempNotes)도 넣는다 — 첫 화면은 그것이 서 있다. */ + function known(pools, baked) { + var set = Object.create(null); // 문장이 키다 — 프로토타입의 이름과 부딪히지 않게 빈 객체로 판다. + Object.keys(pools).forEach(function (k) { + (pools[k] || []).forEach(function (t) { set[t] = true; }); + }); + Object.keys(baked || {}).forEach(function (k) { set[baked[k]] = true; }); + return set; + } + var skyKnown = known(skyPools, w.notes); + var tempKnown = known(tempPools, w.tempNotes); + if (!Object.keys(skyKnown).length && !Object.keys(tempKnown).length) return; + + var nextSky = bagger(skyPools); + var nextTemp = bagger(tempPools); + var sky = null; // 지금 하늘 케이스 + var band = null; // 지금 기온대 + var line = {sky: null, temp: null}; + + /* 문장 하나를 갈아 끼운다. 접었다 펴는 건 눈이 따라오게 하려는 것뿐이라, 실패하면 그냥 바꾼다. */ + function put(node, text) { + if (!node || !text || node.textContent.trim() === text) return; + if (REDUCED) { node.textContent = text; return; } + node.setAttribute('data-w4d-fade', ''); + node.style.opacity = '0'; + window.setTimeout(function () { + node.textContent = text; + node.style.opacity = ''; + }, 300); + } + + /* 하늘 줄은

    자체가 문장이고, 기온 줄은 배지 옆의 이 문장이다. + 클래스로 찾지 않는다 — 번들 판이 바뀌면 달라지고 두 줄 다 id 가 없다. */ + function apply() { + var section = document.getElementById('weather'); + if (!section) return; + var nodes = section.querySelectorAll('p, p span'); + for (var i = 0; i < nodes.length; i += 1) { + var node = nodes[i]; + if (node.children.length) continue; // 잎만 본다 + var text = node.textContent.trim(); + if (line.sky && skyKnown[text]) { put(node, line.sky); continue; } + if (line.temp && tempKnown[text]) { put(node, line.temp); } + } + } + + function reroll() { + if (sky) line.sky = nextSky(sky) || line.sky; + if (band) line.temp = nextTemp(band) || line.temp; + apply(); + } + + var pending = null; + var observer = new window.MutationObserver(function () { + window.clearTimeout(pending); + pending = window.setTimeout(apply, 100); + }); + var root = document.getElementById('root'); + if (root) observer.observe(root, {childList: true, subtree: true, characterData: true}); + + /* 렌더러가 보는 그 엔드포인트다(같은 오리진). 실패하면 구운 문장을 그대로 둔다. */ + function refresh() { + if (!place.regionCode || place.latitude == null) return; + var query = 'region_code=' + encodeURIComponent(place.regionCode) + + '&latitude=' + place.latitude + '&longitude=' + place.longitude; + window.fetch('/v1/local/weather?' + query) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (body) { + if (!body || !body.weather) return; + var nextSkyCase = skyOf(Number(body.weather.weather_code)); + var nextBand = bandOf(Number(body.weather.temperature)); + // 케이스가 그대로면 문장도 그대로 둔다 — 10분마다 눈앞에서 갈리면 안 된다. + if (nextSkyCase === sky && nextBand === band) return; + sky = nextSkyCase; + band = nextBand; + reroll(); + }) + .catch(function () { /* 구운 문장 그대로 */ }); + } + + /* 굽힌 값으로 먼저 한 벌 뽑아 둔다 — 날씨 API 가 늦거나 죽어도 문장은 돌아간다. + 구운 payload 에는 코드가 없고 사람이 읽는 문자열뿐이라 그쪽에서 되짚는다. */ + sky = skyOfText(String(w.condition || '')); + band = bandOf(Number(w.temperature)); + reroll(); + refresh(); + window.setInterval(refresh, 10 * 60 * 1000); + + } + + /* ══ ⑧ 사진 갤러리 쪽 넘김 — 넓은 화면에서만 ═══════════════════════════════ + * ★ 왜 (2026-09-14 사장님: "사진갤러리 전체 렌더링 말고 페이징 좀. 너무 많아 데스크톱에서는") + * `GallerySection` 은 좁은 화면에서 한 장씩 미는 캐러셀이고, 넓은 화면에서는 같은