From 6b9e01d87621d2a4378512089b207cfa0d2e75a0 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 15 Sep 2026 09:27:32 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution:=20=EC=A7=80=EC=97=AD=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EC=84=B9=EC=85=98=20=C2=B7=20=EC=97=BD?= =?UTF-8?q?=EC=84=9C=20=EA=B3=B5=EC=9C=A0=EB=A5=BC=20=ED=92=80=EA=B3=A0=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B0=EC=A1=B4=20=EC=82=AC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EB=8A=94=20=EC=9E=90=EC=82=B0=20=EC=A3=BC=EC=86=8C=EB=A7=8C=20?= =?UTF-8?q?=EA=B0=88=EC=95=84=20=EB=81=BC=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세 가지가 한 줄기다 — 목업에만 있던 것을 제품으로 옮기면서, 그게 이미 나가 있는 사이트를 건드리지 않게 하는 데까지가 한 변경이다. ① 지역 읽기(mockup/README T7) — 목업은 주입 스크립트로 그렸고 렌더러엔 없었다. 새로 발행한 업장에서는 영영 빈자리였다(`daily` 가 프롬프트를 빌더에만 둬서 서버가 그 종류를 몰랐던 것과 같은 사고). · shared: `ReadingItem` · `SECTION_ITEM_REQUIRED_KEY.reading` · `reading` 프롬프트 (프롬프트 단일 출처는 `section-prompts.ts` 하나다 — 코드에 문장을 박지 않는다) · backend: STORY_KINDS 등록. `_SEARCH_LINK_KINDS` — 이 종류는 모델의 URL 을 안 받고 제목으로 만든 네이버 검색 링크를 코드가 붙인다(주소를 짐작해 적으면 없는 문서로 간다) · site: '지역 이야기' 여섯 번째 탭. 구운 HTML 은 앞에서 여섯 꼭지, 붙은 뒤 한 번 섞는다 · 탭 이름은 `{지명} 읽기` — '군산' 을 코드에 박지 않는다 ② 엽서 공유가 모든 발행 사이트에서 막혀 있던 것. 사진이 `*.pstatic.net` · `tong.visitkorea.or.kr` 에 있고 그쪽이 `Access-Control-Allow-Origin` 을 안 준다 (실측 세 곳 모두 없음) — 캔버스가 오염돼 `toBlob` 이 죽는다. 클라이언트에서는 못 넘는다. · `prerender.ts mirrorMedia`: 굽기 전에 `s//img/<주소해시>.<확장자>` 로 받고 payload 주소를 우리 오리진 절대주소로 바꾼다(og:image·JSON-LD 도 같은 값을 쓴다) · 못 받으면 원래 주소를 쓴다. 파일명이 주소 해시라 다시 구워도 안 받는다 · `originUrl`·`sourceType` 은 그대로 — DECISIONS 1-2 가 "불가" 면 CRAWL 제외가 먹어야 한다 · 엽서 미리보기를 240px 로 묶었다(대표: "엽서 ui 너무 큼") ③ **기동이 전부 다시 굽지 않는다** (대표: "전체 재굽기 할 필요가 없어, 사장님이 재발행하면 끝인데 / css js만 안 깨지게 하란 말이야"). 렌더러를 한 줄 고칠 때마다 이미 나가 있는 사이트의 HTML 이 통째로 바뀌던 자리다. · `watch-payloads.mjs`: 기동 = `--refresh-assets` 하나. 한 번도 안 구워진 payload 만 굽는다 · `prerender.ts refreshBakedAssets`: 구워진 HTML 의 `assets/index-<해시>.css|js` 파일명만 새 번들로 바꾼다. 내용·payload·접두사는 그대로. 보호 슬러그는 건너뛴다 · 그래서 ①②는 **다음 발행 때** 그 사이트에 들어간다 문서: AGENTS.md 함정 둘(사진 내려받기 · 기동은 안 굽는다) 추가, 전체 재굽기를 전제하던 옛 항목 둘을 고쳤다. mockup/README T7 은 "제품에 들어갔다" 로, DATA_MODEL 의 STORY kind 목록 갱신. 검증: tsc·eslint 통과(site·frontend), site 79 passed(읽기 4건 추가). 실측 — buru 굽기: 사진 10장 내려받고 og:image 가 우리 주소, 재굽기 때 0건; `--refresh-assets`: 옛 해시로 바꿔 둔 index.html 1곳이 새 번들 주소로 바뀌고 내용은 그대로. 백엔드 테스트는 이 기계의 5432 가 다른 터널에 물려 있어 못 돌렸다. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 45 +++- docs/DATA_MODEL.md | 2 +- solution/backend/common/enums.py | 6 +- solution/backend/services/grounding/story.py | 32 ++- .../services/prompts/section_prompts.json | 7 + solution/backend/services/site_payload.py | 2 +- solution/backend/services/story_service.py | 2 +- .../backend/tests/test_story_generation.py | 2 +- .../src/features/builder/canvas/dataSpec.ts | 46 +++++ .../src/features/builder/canvas/registry.ts | 12 ++ .../canvas/variants/reading/ReadingRail.tsx | 93 +++++++++ solution/shared/src/lib/section-data.ts | 27 +++ solution/shared/src/lib/section-prompts.ts | 40 +++- solution/shared/src/types/site-payload.ts | 6 +- solution/site/scripts/mockup/README.md | 26 ++- solution/site/scripts/prerender.ts | 195 +++++++++++++++++- solution/site/scripts/watch-payloads.mjs | 38 +++- .../src/layouts/editorial/SectionHead.tsx | 2 + solution/site/src/lib/postcard-canvas.ts | 5 +- .../sections/items/PostcardMakerSection.tsx | 32 +-- .../src/sections/items/ReadingSection.tsx | 96 +++++++++ .../site/src/sections/items/StorySection.tsx | 31 ++- solution/site/src/sections/items/index.ts | 4 +- .../site/src/sections/items/reading.test.tsx | 62 ++++++ 24 files changed, 761 insertions(+), 52 deletions(-) create mode 100644 solution/frontend/src/features/builder/canvas/variants/reading/ReadingRail.tsx create mode 100644 solution/site/src/sections/items/ReadingSection.tsx create mode 100644 solution/site/src/sections/items/reading.test.tsx diff --git a/AGENTS.md b/AGENTS.md index afd8354..74b091e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,37 @@ 구워 목업을 통째로 날렸다(캐치프레이즈 100개·미니 플레이어·날씨 문구·주입분 전부). 그 payload 는 `solution/site/payloads-mockup-hold/` 로 옮긴다 — 지우면 재발행 때 또 온다. +- **★ 굽기는 네트워크를 탄다 — 사진을 내려받는다** (`prerender.ts` `mirrorMedia`). + `payload.media[].url` 이 남의 도메인이면 `out/s//img/<주소해시>.<확장자>` 로 받아 놓고 + payload 의 주소를 **우리 오리진 절대주소**로 바꾼 뒤에 굽는다. 이유는 캔버스다 — + 수집처(`*.pstatic.net` · `tong.visitkorea.or.kr`)가 `Access-Control-Allow-Origin` 을 안 줘서 + 그 사진을 캔버스에 그리면 오염돼 `toBlob` 이 막히고, **엽서 쓰기의 저장·공유가 모든 발행 + 사이트에서 죽어 있었다**(실측 2026-09-15). 클라이언트에서는 못 넘는다. + → 못 받은 사진은 **원래 주소를 그대로 쓴다**(사진이 사라지는 것보다 낫다). 로그에 한 줄 남는다. + → 주소가 그대로면 파일명도 그대로라 **다시 구워도 내려받지 않는다.** 처음 한 번만 느리다. + → ★ **이미 나가 있는 사이트는 그대로 둔다.** 새 기능은 사장님이 **다시 발행할 때** 들어간다 + (아래 항목). + → `originUrl` · `sourceType` 은 손대지 않는다 — 재게시 권리(DECISIONS 1-2)가 "불가" 로 + 결론 나면 `sourceType = CRAWL` 을 빼는 그 대응이 그대로 먹어야 한다. +- **★★ 배포해도 기존 사이트를 다시 굽지 않는다 — 자산 주소만 갈아 끼운다.** + (2026-09-15 대표 지시: "전체 재굽기 할 필요가 없어, 사장님이 재발행하면 끝인데 / + css js만 안 깨지게 하란 말이야") + 예전에는 `solution-prerender` 가 뜰 때마다 payload 를 **전부 다시 구웠다.** 그러면 렌더러를 + 한 줄 고칠 때마다 이미 나가 있는 사이트의 HTML 이 통째로 바뀐다 — 사장님은 발행한 적이 + 없는데 내용이 달라지고, 구글이 다시 읽어 가는 값도 달라진다. + 지금 기동이 하는 일은 `prerender.js --refresh-assets` 하나다 + (`watch-payloads.mjs` `refreshAssets` → `prerender.ts` `refreshBakedAssets`): + → 구워진 `index.html` 안의 `assets/index-<해시>.css|js` **파일명만** 새 번들로 바꾼다. + 내용·구조·payload 는 손대지 않는다. 접두사(`/assets` · `/sites/assets`)도 그대로 둔다. + → **한 번도 안 구워진 payload 만** 굽는다(볼륨이 비었거나 감시가 꺼진 새 발행). + → ★ **payload 가 없는 디렉토리는 건드리지 않는다**(목업 `stay3` · `*.old`, 그리고 + `PROTECTED_SLUGS`). 손으로 만든 유일본에 최신 번들을 물렸다가 깨지면 되돌릴 수 없다 — + 그쪽 번들 교체는 사람이 한다(mockup/README "번들만 갈아 끼운다"). + 대상은 `--payload-dir` 에 `<슬러그>.json` 이 있는 사이트뿐이다. + ★ 그래서 **정적 HTML 은 옛 렌더러의 것이고 스크립트는 새 렌더러**다. 어긋나면 리액트가 + 그 자리에서 다시 그리므로 손님 화면은 새것이지만, **크롤러가 읽는 HTML 은 옛것**이다. + 둘을 맞추는 방법은 재발행뿐이고 그건 사장님이 누른다. 급하면 `republish_all.py` 지만 + **먼저 묻는다** — 전 사이트의 발행일이 한꺼번에 움직이는 일이다. - **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로 가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다 (`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다. @@ -56,15 +87,19 @@ 없고**, 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다. 옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. → 자산을 지우는 코드를 손볼 때는 **"기록이 없다"와 "만료됐다"를 절대 같이 묶지 않는다.** - → 이미 끊겼다면 복구는 `docker compose restart solution-prerender` (기동하며 전체 재굽기). + → 이미 끊겼다면 복구는 `docker compose restart solution-prerender` — 기동이 공용 자산을 + 다시 깔고 구워진 HTML 의 자산 주소를 맞춘다(전체 재굽기가 아니다, 위 ★★ 항목). - **★ 사이트를 굽는 컨테이너는 `solution-prerender` 다.** `solution-frontend` 는 **개발용**이라 운영에서는 아예 뜨지 않는다(`docker-compose.yml` `profiles: ["dev"]`). 이름이 비슷해서 `restart solution-frontend` 를 치면 **아무 일도 안 일어나는데 명령은 성공한다** — 재굽기를 했다고 믿고 넘어가게 된다. 실제로 그렇게 복구가 한 번 헛돌았다(2026-09-07). -- **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** - `azure_static.publish(slug)` 는 공용 자산 + `s/` 만 올린다 — - **렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.** - → `docker compose restart solution-prerender` 후 `python scripts/republish_all.py` +- **★ 프론트(`solution/site`)를 고쳐도 기존 사이트의 내용은 안 바뀐다.** 기동은 자산 주소만 + 맞춘다(위 ★★ 항목) — 새 렌더러로 다시 그려지는 건 **그 사장님이 다시 발행할 때**다. + Azure 를 쓰는 경우엔 한 겹 더 있다: `azure_static.publish(slug)` 는 공용 자산 + `s/` 만 + 올린다 — 다른 사이트의 블롭은 그대로다. + → 전 사이트를 한꺼번에 새 렌더러로 맞춰야 할 일이 생기면 + `docker compose restart solution-prerender` 후 `python scripts/republish_all.py` 인데, + **먼저 묻는다**(발행일이 전부 움직인다). - **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `web4ai.o2osolution.ai`, `site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부 이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더 diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index f3f9206..a386af1 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -170,7 +170,7 @@ Gemini 가 쓰고, 곡은 Suno 가 붙인다. |---|---|---| | 1 WEATHER | Open-Meteo | — | | 2 FESTIVAL · 3 ATTRACTION · 4 RESTAURANT · 5 COURSE | TourAPI (좌표 반경) | — | -| 6 STORY | Perplexity | `songs` `people` `chronicle` `postcard` `quiz` | +| 6 STORY | Perplexity | `songs` `daily` `people` `chronicle` `reading` `postcard` `quiz` | `body`(JSONB)에 항목이 들어간다. **지역 이야기는 종류당 한 행**이고 항목들은 `body.items` 안에 있다. diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index ca21b90..b1e06e3 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -318,7 +318,7 @@ class LocalContentType(CodeEnum): # 코드값 ↔ **타입명**. `area_contents.kind` 와 `site_sections.data.items[].kind` 가 같은 어휘를 쓴다 — # 개인화 행(거리·숨김)이 어느 공용 실체를 가리키는지 이름만 보고 알 수 있어야 한다. -# ★ STORY 는 여기 없다. 그 다섯(songs·people·chronicle·postcard·quiz)은 kind 가 곧 타입명이고, +# ★ STORY 는 여기 없다. 그것들(songs·people·chronicle·reading·postcard·quiz)은 kind 가 곧 타입명이고, # 코드값 하나(6)를 나눠 쓴다. 아래 표는 kind 가 비어 있던 장소류를 채우기 위한 것이다. AREA_KIND = { LocalContentType.WEATHER.value: "weather", @@ -328,9 +328,9 @@ AREA_KIND = { LocalContentType.COURSE.value: "course", } -# 지역 이야기 여섯. `services/prompts/story.py` 의 산출물 키와 같아야 한다. +# 지역 이야기 일곱. `services/prompts/story.py` 의 산출물 키와 같아야 한다. # ★ 순서는 발행본 '지역 이야기' 탭 순서다(`site/sections/items/StorySection.tsx`). -STORY_KINDS = ("songs", "daily", "people", "chronicle", "postcard", "quiz") +STORY_KINDS = ("songs", "daily", "people", "chronicle", "reading", "postcard", "quiz") class LocalSource(CodeEnum): diff --git a/solution/backend/services/grounding/story.py b/solution/backend/services/grounding/story.py index 1c6441a..161238a 100644 --- a/solution/backend/services/grounding/story.py +++ b/solution/backend/services/grounding/story.py @@ -11,9 +11,12 @@ `search_results` 의 첫 줄을 붙인다 — 모델 답변은 환각이 섞이지만 search_results 는 실제로 검색된 주소다(`grounding/channels.py` 와 같은 판단). 둘 다 없으면 항목을 버린다. 출처 없는 사실은 이 레포의 규칙 위반이다. + ★ 예외가 하나 있다 — `_SEARCH_LINK_KINDS`. 거기 있는 종류는 **모델의 URL 을 아예 안 받고** + 제목으로 찾아가는 검색 링크를 코드가 만든다. """ import json import re +from urllib.parse import quote from common.logger import LOG @@ -23,10 +26,27 @@ _TITLE_KEY = { "daily": "title", "people": "name", "chronicle": "title", + "reading": "title", "postcard": "line", "quiz": "question", } +# ★ 출처를 **모델에게 받지 않고 코드가 만드는** 종류. +# 개별 문서 주소(`terms.naver.com/entry.naver?docId=…`)는 짐작해서 적으면 없는 문서로 +# 이어진다 — 레포 절대규칙(없는 사실을 짓지 않는다)에 걸린다. 검색 링크는 제목을 그대로 +# 넘기니 항상 관련 결과로 뜬다. 2026-09-14 대표 지시("확인필요 없애고 링크는 네이버 링크로"), +# 시연본 구현은 `site/scripts/mockup/build_reading.py` 의 `naver()`. +_SEARCH_LINK_KINDS = {"reading"} + + +def _search_source(title: str, region_label: str) -> dict: + """제목으로 찾아가는 검색 링크. 지역명이 제목에 없으면 붙여 검색 정확도를 올린다.""" + region = (region_label or "").split()[-1] if region_label else "" + query = title if (not region or region[:-1] in title or region in title) else f"{title} {region}" + return {"name": "네이버에서 찾아보기", + "url": "https://search.naver.com/search.naver?query=" + quote(query)} + + # 코드펜스를 두르고 오는 경우가 있다. 규칙 1 로 금지했지만 모델은 종종 어긴다. _FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE) @@ -57,7 +77,9 @@ def _clean_source(value) -> dict | None: return {"name": (value.get("name") or url).strip(), "url": url} -def parse_items(payload: dict, kind: str, limit: int) -> tuple[list[dict], list[str]]: +def parse_items( + payload: dict, kind: str, limit: int, region_label: str = "" +) -> tuple[list[dict], list[str]]: """(쓸 수 있는 항목, 버린 이유) — 버린 이유는 로그와 잡 결과에 남긴다. 한 항목이 잘못돼도 나머지를 살린다. 지역 하나에 8~14건인데 한 줄 때문에 전부 버리면 @@ -101,6 +123,14 @@ def parse_items(payload: dict, kind: str, limit: int) -> tuple[list[dict], list[ item = {k: v for k, v in raw.items() if v not in (None, "", [], {})} item[title_key] = title + if kind in _SEARCH_LINK_KINDS: + # 출처는 코드가 만든다(위 _SEARCH_LINK_KINDS). 모델이 URL 을 적어 왔어도 버린다 — + # 짐작해 적은 주소가 섞이는 통로를 아예 남기지 않는다. 검수 배지도 함께 뺀다. + item["source"] = _search_source(title, region_label) + item.pop("verified", None) + out.append(item) + continue + source = _clean_source(raw.get("source")) or fallback if source is None: dropped.append(f"{title}: 출처가 없다") diff --git a/solution/backend/services/prompts/section_prompts.json b/solution/backend/services/prompts/section_prompts.json index 533a2df..2b2460a 100644 --- a/solution/backend/services/prompts/section_prompts.json +++ b/solution/backend/services/prompts/section_prompts.json @@ -30,6 +30,13 @@ "task": "[해야 할 일]\n[지역]의 역사를 연도순으로 10~14개 사건으로 정리한다.\n가장 오래된 것부터 가장 최근까지 고르게 펴고, 한 시대에 몰지 않는다.\n\n[스키마]\n{ \"kind\":\"chronicle\", \"version\":1, \"title\":\"시간의 골목\", \"items\":[\n { \"year\":1899, \"title\":\"사건 이름\", \"summary\":\"두 문장 이내\",\n \"place\":\"지금 가 볼 수 있는 자리\", \"turning\":true,\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }", "rules": "\n[이 아이템만의 규칙]\n· turning 은 도시의 성격을 바꾼 해에만 true 다. 3~4개를 넘기지 않는다.\n· 연도가 불확실하면 그 항목을 통째로 뺀다. 연표에서 틀린 연도는 바로 들킨다.\n· place 는 지금도 찾아갈 수 있는 자리만 적는다. 없으면 필드를 뺀다.\n· 다만 **찾아갈 수 있는 자리가 있으면 반드시 적는다.** 행정 개편처럼 장소가 없어 보이는\n 사건에도 그 일이 남긴 자리가 대개 있다(개항 → 항구, 부두 건설 → 그 부두, 준공 → 그 구조물).\n 이 값으로 공공데이터에서 그 해의 사진을 찾아 붙인다 — 비면 연표가 활자만으로 선다.\n" }, + "reading": { + "kind": "reading", + "label": "지역 읽기", + "maxItems": 34, + "task": "[해야 할 일]\n[지역]을 소개하는 글 30~34꼭지를 갈래로 나눠 쓴다.\n갈래는 다섯이다 — 문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활.\n한 갈래에 몰지 않되, 댈 수 있는 사실이 적은 갈래는 적게 쓴다.\n\n[스키마]\n{ \"kind\":\"reading\", \"version\":1, \"title\":\"[지역] 읽기\",\n \"subtitle\":\"문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활 — 올 때마다 다른 몇 가지\",\n \"items\":[\n { \"group\":\"문학|섬과 바다|역사|장소|음식과 생활\",\n \"title\":\"꼭지 제목\", \"body\":\"서너 문장\", \"year\":1937 } ] }", + "rules": "\n[이 아이템만의 규칙]\n· body 는 **서너 문장**이다. 두 문장이면 카드가 한 줄로 접혀 빈 카드처럼 보인다.\n 다만 문장을 늘리려고 없는 숫자·연도·거리를 지어내지 않는다 — 널리 알려진 배경만 보탠다.\n· **이미 다른 자리에 선 것은 쓰지 않는다.** 인물·대중가요·축제·연표 사건·주변 명소·맛집은\n 각자 섹션이 있다. 같은 것을 두 번 세우면 페이지만 길어지고 손님은 같은 말을 두 번 읽는다.\n· year 는 연도를 댈 수 있는 꼭지에만 쓴다. 짐작해서 넣지 않는다.\n· source 와 verified 를 적지 않는다. **주소를 짐작해 적으면 없는 문서로 이어진다** —\n 제목으로 찾아가는 검색 링크를 서버가 붙인다(2026-09-14 대표 지시).\n· 시·소설·가사의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다.\n· 부제에 전체 개수를 적지 않는다. 화면은 이 중 5~6개만 매번 무작위로 보여준다 —\n 숫자를 박으면 보이는 개수와 어긋난다.\n" + }, "postcard": { "kind": "postcard", "label": "오늘의 엽서", diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index cd64572..e8f6ae3 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -140,7 +140,7 @@ _DEFAULT_THEME = { # 수집이 다 됐어도 그 섹션이 아예 안 나온다 — 저장값이 없는 사업장에는 이 표가 곧 발행본이다. # ★ "이용 규정"은 뺐다 (2026-09-09) — 발행본에 그 섹션이 없다. 체크인·취소·취사· # 반려동물 줄은 기본 정보 안에서 규정 덩이로 묶여 나간다(EssentialInfoSection). - # ★ 가요·일력·인물·연표·엽서는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데 + # ★ 가요·일력·인물·연표·읽기·엽서는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데 # (`_sections`), 그것들은 '지역 이야기'(story) 탭 **안에서** 그려지는 것이라 # 켜면 탭 밖에 한 번 더 선다. story 하나만 두면 데이터가 있는 것만 탭이 된다. # ★ 퀴즈(quiz)도 넣지 않지만 사정이 다르다 — 탭이 아니라 **독립 섹션**이라 diff --git a/solution/backend/services/story_service.py b/solution/backend/services/story_service.py index d962835..75f101d 100644 --- a/solution/backend/services/story_service.py +++ b/solution/backend/services/story_service.py @@ -74,7 +74,7 @@ async def _generate_kind(client: httpx.AsyncClient, kind: str, region_label: str LOG.w(f"[story] {kind} 호출 실패 region={region_label}: {ex}") return [], [f"호출 실패: {ex}"] - items, dropped = grounding.parse_items(payload, kind, prompts.max_items(kind)) + items, dropped = grounding.parse_items(payload, kind, prompts.max_items(kind), region_label) items = await _attach_images(kind, items, region_label) LOG.i(f"[story] {region_label} {kind}: {len(items)}건 채택, {len(dropped)}건 버림") return items, dropped diff --git a/solution/backend/tests/test_story_generation.py b/solution/backend/tests/test_story_generation.py index 2195fa4..a9f2590 100644 --- a/solution/backend/tests/test_story_generation.py +++ b/solution/backend/tests/test_story_generation.py @@ -23,7 +23,7 @@ def _reply(content: str, search_results=None) -> dict: # ── 프롬프트 ──────────────────────────────────────────────────────────── def test_프롬프트는_shared_산출물에서_온다(): """여섯 종이 모두 있고, 지역명이 빈칸 없이 박힌다.""" - assert set(prompts.kinds()) == {"songs", "daily", "people", "chronicle", "postcard", "quiz"} + assert set(prompts.kinds()) == {"songs", "daily", "people", "chronicle", "reading", "postcard", "quiz"} text = prompts.build_prompt("songs", "전북 군산시") assert "[지역] 전북 군산시" in text assert "[지역]을 노래한 대중가요" in text # task 원문 diff --git a/solution/frontend/src/features/builder/canvas/dataSpec.ts b/solution/frontend/src/features/builder/canvas/dataSpec.ts index 708ac45..6941f2a 100644 --- a/solution/frontend/src/features/builder/canvas/dataSpec.ts +++ b/solution/frontend/src/features/builder/canvas/dataSpec.ts @@ -343,6 +343,52 @@ export const SECTION_DATA_SPEC: Record = { ], }, + reading: { + kind: 'reading', + label: '지역 읽기', + sample: JSON.stringify( + { + kind: 'reading', + version: 1, + title: '군산 읽기', + subtitle: '문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활 — 올 때마다 다른 몇 가지', + items: [ + { + group: '문학', + title: '채만식 『탁류』', + body: + '일제강점기 군산을 무대로 한 장편소설입니다. 쌀을 사고팔던 미두장을 중심으로, ' + + '가족을 지키려던 초봉이 시대의 소용돌이에 휩쓸려 무너지는 과정을 그렸습니다. ' + + '금강 하구의 흙탕물이라는 제목처럼, 개항장 군산의 실제 지명과 풍경 위에 ' + + '이야기가 펼쳐집니다.', + year: 1937, + }, + { + group: '섬과 바다', + title: '고군산군도', + body: + '예순 남짓한 섬이 모인 군도입니다. 새만금 방조제가 육지와 신시도를 잇고, ' + + '그 뒤로 무녀도 · 선유도 · 장자도가 다리로 차례로 이어져 차로 들어갈 수 ' + + '있습니다. 섬마다 성격이 달라 하루로는 다 못 봅니다.', + }, + ], + }, + null, + 2, + ), + task: SECTION_PROMPTS.reading.task, + rules: SECTION_PROMPTS.reading.rules, + /* 출처 칸이 없다 — 이 종류의 출처는 서버가 제목으로 만든 검색 링크다 + (`backend/services/grounding/story.py` `_SEARCH_LINK_KINDS`). 손으로 적는 칸을 두면 + 거기 적힌 주소가 그 규칙을 비껴간다. */ + fields: [ + {key: 'group', label: '갈래', half: true, hint: '문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활'}, + {key: 'year', label: '연도', type: 'number', half: true}, + {key: 'title', label: '꼭지 제목'}, + {key: 'body', label: '본문', type: 'area', hint: '서너 문장'}, + ], + }, + postcard: { kind: 'postcard', label: '오늘의 엽서', diff --git a/solution/frontend/src/features/builder/canvas/registry.ts b/solution/frontend/src/features/builder/canvas/registry.ts index a48e191..1ed0977 100644 --- a/solution/frontend/src/features/builder/canvas/registry.ts +++ b/solution/frontend/src/features/builder/canvas/registry.ts @@ -64,6 +64,7 @@ import {SongsTurntable} from './variants/songs/SongsTurntable'; import {DailyCalendar} from './variants/daily/DailyCalendar'; import {PeopleFilmstrip} from './variants/people/PeopleFilmstrip'; import {ChronicleRail} from './variants/chronicle/ChronicleRail'; +import {ReadingRail} from './variants/reading/ReadingRail'; import {PostcardStack} from './variants/postcard/PostcardStack'; import {QuizFlip} from './variants/quiz/QuizFlip'; @@ -413,6 +414,17 @@ export const SECTION_VARIANTS: Record = { ], + reading: [ + { + id: 'reading.posts', + name: '읽기 카드', + description: '한 슬라이드에 한 꼭지. 발행본은 이 중 5~6개만 매번 무작위로 세운다.', + thumb: 'carousel', + Component: ReadingRail, + isDefault: true, + }, + ], + postcard: [ { id: 'postcard.stack', diff --git a/solution/frontend/src/features/builder/canvas/variants/reading/ReadingRail.tsx b/solution/frontend/src/features/builder/canvas/variants/reading/ReadingRail.tsx new file mode 100644 index 0000000..de1c1b9 --- /dev/null +++ b/solution/frontend/src/features/builder/canvas/variants/reading/ReadingRail.tsx @@ -0,0 +1,93 @@ +/** + * 지역 읽기 — 한 슬라이드에 한 꼭지. + * + * ★ 에디터는 **전부 보여준다.** 발행본은 이 중 5~6개만 매번 무작위로 세우지만 + * (`site/sections/items/ReadingSection.tsx`), 고칠 사람은 안 뽑힌 꼭지도 봐야 한다 — + * 에디터에서 무작위로 가리면 방금 고친 글이 다음 렌더에 사라진다. + * ★ 출처 줄이 없다. 이 종류의 출처는 제목으로 만든 검색 링크라 서버가 붙인다 + * (`backend/services/grounding/story.py` `_SEARCH_LINK_KINDS`) — 붙여넣기 단계에는 없다. + */ +import {Rail, SectionBody, SectionFrame} from '../../primitives'; +import type {SectionRenderProps} from '../../types'; +import {parseSectionData, type ReadingItem} from '@o2o/shared'; +import { + ITEM_ACCENT, + ITEM_BODY, + ITEM_BORDER, + ITEM_CARD, + ITEM_HEADING, + ParseError, + PasteHint, +} from '../items/common'; +import '../items/items.css'; + +function Post({item}: {item: ReadingItem}) { + return ( +
+ {item.group && ( +

+ {item.group} + {item.year != null && · {item.year}} +

+ )} +

+ {item.title} +

+

+ {item.body} +

+
+ ); +} + +export function ReadingRail(props: SectionRenderProps) { + const {section, isSelected, onSelect} = props; + const parsed = parseSectionData(section.type, section.data); + + return ( + + +
+

+ {parsed.title || section.name} +

+ {(parsed.subtitle || section.description) && ( +

+ {parsed.subtitle || section.description} +

+ )} +
+ + {parsed.error ? ( + + ) : parsed.items.length === 0 ? ( + + ) : ( +
+ {/* 발행본과 에디터가 다르게 보이는 자리라, 몇 개가 나가는지 여기서 밝힌다. */} +

+ 전체 {parsed.items.length}꼭지 · 발행본은 이 중 5~6개를 매번 무작위로 세웁니다 +

+ + {parsed.items.map((item, index) => ( + + ))} + +
+ )} +
+
+ ); +} diff --git a/solution/shared/src/lib/section-data.ts b/solution/shared/src/lib/section-data.ts index 9147a0d..7a480d6 100644 --- a/solution/shared/src/lib/section-data.ts +++ b/solution/shared/src/lib/section-data.ts @@ -125,6 +125,32 @@ export interface ChronicleItem { source?: DataSource; } +/** + * 지역 읽기 — 도시를 갈래로 묶어 한 꼭지씩 넘겨 보는 글. + * + * ★ 왜 일력(daily)과 따로 있나 (2026-09-14 대표 의견: "오늘의 한 장으로 군산을 소개하기는 + * 무리다, 하루에 하나씩밖에 안 알려 주니까") + * 일력은 오늘·어제·내일 **세 장만** 화면에 선다. 도시를 소개하는 그릇으로는 작다. + * 이쪽은 갈래(문학·섬과 바다·역사·장소·음식과 생활)로 묶은 더미에서 매번 몇 개만 뽑아 낸다. + * ★ 이미 제 자리를 가진 것은 여기 넣지 않는다 — 인물·가요·축제·연표·명소·맛집. + * 같은 이야기를 두 번 세우면 페이지만 길어진다(프롬프트 규칙으로도 막는다). + */ +export interface ReadingItem { + /** 갈래 이름. 카드 위 이름표로만 쓴다 — 갈래별로 묶어 세우지 않는다(뽑기는 갈래를 안 가린다). */ + group?: string; + title: string; + /** 서너 문장. 두 문장짜리는 카드가 한 줄로 접혀 빈 카드처럼 보인다(2026-09-14 대표 지적). */ + body: string; + /** 연도를 댈 수 있는 꼭지만. 없으면 이름표에 연도를 안 붙인다. */ + year?: number; + verified?: DataVerified; + /** + * 출처. **모델이 적지 않는다** — 제목으로 찾아가는 검색 링크를 서버가 붙인다 + * (`services/grounding/story.py`). 개별 문서 주소를 짐작해 적으면 없는 문서로 이어진다. + */ + source?: DataSource; +} + export interface LiteratureItem { workTitle: string; author?: string; @@ -288,6 +314,7 @@ export const SECTION_ITEM_REQUIRED_KEY: Record = { schedule: 'name', people: 'name', chronicle: 'title', + reading: 'title', literature: 'workTitle', postcard: 'line', quiz: 'question', diff --git a/solution/shared/src/lib/section-prompts.ts b/solution/shared/src/lib/section-prompts.ts index ef40843..94c26f3 100644 --- a/solution/shared/src/lib/section-prompts.ts +++ b/solution/shared/src/lib/section-prompts.ts @@ -15,7 +15,14 @@ */ /** 아이템 종류. `area_contents.kind` · JSON 봉투의 `kind` 와 같은 값이다. */ -export type StoryKind = 'songs' | 'daily' | 'people' | 'chronicle' | 'postcard' | 'quiz'; +export type StoryKind = + | 'songs' + | 'daily' + | 'people' + | 'chronicle' + | 'reading' + | 'postcard' + | 'quiz'; /** * 순서는 발행본 '지역 이야기' 탭 순서다(`site/sections/items/StorySection.tsx`). @@ -30,6 +37,7 @@ export const STORY_KINDS: StoryKind[] = [ 'daily', 'people', 'chronicle', + 'reading', 'postcard', 'quiz', ]; @@ -157,6 +165,36 @@ export const SECTION_PROMPTS: Record = { `, }, + reading: { + kind: 'reading', + label: '지역 읽기', + maxItems: 34, + task: `[해야 할 일] +[지역]을 소개하는 글 30~34꼭지를 갈래로 나눠 쓴다. +갈래는 다섯이다 — 문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활. +한 갈래에 몰지 않되, 댈 수 있는 사실이 적은 갈래는 적게 쓴다. + +[스키마] +{ "kind":"reading", "version":1, "title":"[지역] 읽기", + "subtitle":"문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활 — 올 때마다 다른 몇 가지", + "items":[ + { "group":"문학|섬과 바다|역사|장소|음식과 생활", + "title":"꼭지 제목", "body":"서너 문장", "year":1937 } ] }`, + rules: ` +[이 아이템만의 규칙] +· body 는 **서너 문장**이다. 두 문장이면 카드가 한 줄로 접혀 빈 카드처럼 보인다. + 다만 문장을 늘리려고 없는 숫자·연도·거리를 지어내지 않는다 — 널리 알려진 배경만 보탠다. +· **이미 다른 자리에 선 것은 쓰지 않는다.** 인물·대중가요·축제·연표 사건·주변 명소·맛집은 + 각자 섹션이 있다. 같은 것을 두 번 세우면 페이지만 길어지고 손님은 같은 말을 두 번 읽는다. +· year 는 연도를 댈 수 있는 꼭지에만 쓴다. 짐작해서 넣지 않는다. +· source 와 verified 를 적지 않는다. **주소를 짐작해 적으면 없는 문서로 이어진다** — + 제목으로 찾아가는 검색 링크를 서버가 붙인다(2026-09-14 대표 지시). +· 시·소설·가사의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다. +· 부제에 전체 개수를 적지 않는다. 화면은 이 중 5~6개만 매번 무작위로 보여준다 — + 숫자를 박으면 보이는 개수와 어긋난다. +`, + }, + postcard: { kind: 'postcard', label: '오늘의 엽서', diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index 75193ed..a714b44 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -204,6 +204,7 @@ import type { PeopleItem, PostcardItem, QuizItem, + ReadingItem, SongItem, } from '../lib/section-data'; @@ -222,7 +223,7 @@ export interface LocalContents { */ itineraries?: ItineraryItem[]; /** - * 지역 이야기 — 서버가 지역 단위로 생성한 가요·일력·인물·연표·엽서·퀴즈. + * 지역 이야기 — 서버가 지역 단위로 생성한 가요·일력·인물·연표·읽기·엽서·퀴즈. * * ★ 사장님이 붙여넣는 같은 종류의 JSON(`theme.sections[].data`)과 **모양이 같다.** * 화면은 둘을 한 배열로 이어 그린다(`StorySection`) — 사장님 값이 앞이다. @@ -235,12 +236,13 @@ export interface LocalContents { syncedAt?: string; } -/** 지역 이야기 여섯 종. 데이터가 없는 종류는 키 자체가 없다 — 빈 배열을 만들지 않는다. */ +/** 지역 이야기 일곱 종. 데이터가 없는 종류는 키 자체가 없다 — 빈 배열을 만들지 않는다. */ export interface LocalStories { songs?: SongItem[]; daily?: DailyItem[]; people?: PeopleItem[]; chronicle?: ChronicleItem[]; + reading?: ReadingItem[]; postcard?: PostcardItem[]; quiz?: QuizItem[]; } diff --git a/solution/site/scripts/mockup/README.md b/solution/site/scripts/mockup/README.md index 5d96099..4369b30 100644 --- a/solution/site/scripts/mockup/README.md +++ b/solution/site/scripts/mockup/README.md @@ -84,6 +84,7 @@ | `local` | 지역 정보 | `local.attractions/restaurants` | TourAPI 수집 (시연본 맛집은 `restaurants.json` 31곳) | | | `itinerary` | **추천 일정** | `data(kind=itinerary)` | `services/itinerary.py` + 빌더 프롬프트 `dataSpec.ts:556` | **T2** | | `story` | 군산 이야기 | 아래 6탭 껍데기 | — | | +| `reading` | 지역 읽기 | `local.story.reading` | LLM `section-prompts.ts` `reading` | | | `songs` | 가요 다방 | `data(kind=songs)` | LLM `section-prompts.ts:67` | **T3 T5** | | `daily` | 오늘의 한 장 | `data(kind=daily)` | LLM `:91` | 시연본은 노래·문학 39장(`build_daily.py`) | | `people` | 인물 열전 | `data(kind=people)` | LLM `:113` | **T3 T5** | @@ -95,8 +96,9 @@ | `weather` | 날씨 | `local.weather` | open-meteo | | | `planner` | 계절별 추천 하루 | `data(kind=planner)` | **없음** | (시연본은 꺼 둠) | -섹션이 아닌 것이 셋 더 있다 — 헤더 음악 플레이어(**T4**) · **군산 읽기**(**T7**, '군산 이야기' -탭 묶음의 여섯 번째 탭) · **엽서 쓰기**(독립 섹션, `festival` 바로 앞). +섹션이 아닌 것이 둘 더 있다 — 헤더 음악 플레이어(**T4**) · **엽서 쓰기**(독립 섹션, +`festival` 바로 앞). **군산 읽기**(T7)는 2026-09-15 에 렌더러로 들어갔다 — 이제 payload 에 +`local.story.reading` 만 있으면 새 업장에서도 탭이 선다. --- @@ -273,11 +275,25 @@ --- -### T7 — 도시를 펼쳐 보이는 자리 +### T7 — 도시를 펼쳐 보이는 자리 ✔ 제품에 들어갔다 (2026-09-15) **증상** 도시 이야기를 담는 자리가 전부 한 번에 하나만 보인다. 하루에 한 장만 보여주는 일력이나, 넉 장짜리 엽서 레일 정도로는 군산이라는 도시를 소개하기엔 부족하다. +**제품에서 어디에 있나** — 이제 주입분이 아니라 렌더러가 그린다. + +| 자리 | 파일 | +|---|---| +| 항목 계약 `ReadingItem` | `shared/src/lib/section-data.ts` | +| 프롬프트(단일 출처) | `shared/src/lib/section-prompts.ts` `reading` | +| 생성·출처 링크 | `backend/services/grounding/story.py` `_SEARCH_LINK_KINDS` | +| 발행본 | `site/src/sections/items/ReadingSection.tsx` — '지역 이야기' 여섯 번째 탭 | +| 빌더 캔버스 | `frontend/.../canvas/variants/reading/ReadingRail.tsx` | + +★ 제품과 시연본이 **한 군데 다르다**: 탭 이름이 `{지명} 읽기` 다(시연본은 '군산 읽기' 고정). +★ 제품은 출처를 모델에게 받지 않는다 — 제목으로 만든 네이버 검색 링크를 서버가 붙인다 + (`build_reading.py` `naver()` 를 그대로 옮긴 것이다). + **할 일** 문학·역사·섬과 바다·장소·음식처럼 갈래로 나눈 도시 이야기를, 한 화면에서 옆으로 넘겨 가며 볼 수 있는 자리를 만든다. @@ -920,7 +936,9 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/" 1. **캐치프레이즈 순환** — 대표 문구 `

` 를 찾아 **그 아래에 줄(`#w4d-sub`)을 붙이고** 단어 단위로 갈아 끼운다. 대표 문구는 안 건드린다 → T1 2. **헤더 미니 플레이어** — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡. **자동재생**은 걸어 보고, 브라우저가 막으면 첫 손길에 켠다(**1.3 T4**) → T4 -3. **군산 읽기** — 렌더러에 없는 섹션. '군산 이야기' 탭 묶음의 **여섯 번째 탭**으로 들어가 +3. **군산 읽기** — ★ **2026-09-15 부터 렌더러에 있다**(`ReadingSection.tsx`, **T7**). 아래는 + 시연본 주입분이 어떻게 굴러가는지의 기록이고, 제품을 고칠 때는 렌더러를 본다. + '군산 이야기' 탭 묶음의 **여섯 번째 탭**으로 들어가 34꼭지 중 5~6개를 매번 무작위로 카로셀(embla, 한 슬라이드에 한 꼭지) 로 보여준다. `startReading()` 이 story 탭 패널들의 마지막(postcard) 뒤에 자기 패널을 꽂고, 자기 탭 버튼은 진짜 탭들의 `[role="tablist"]` 끝에 붙인다 → **T7** diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index bcd3a4b..1a05da0 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -9,6 +9,8 @@ import { statSync, writeFileSync, } from 'node:fs'; +import {createHash} from 'node:crypto'; +import {writeFile as writeFileAsync} from 'node:fs/promises'; import {basename, dirname, join, resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; import {SourceType, type SitePayload} from '@o2o/shared'; @@ -112,14 +114,25 @@ interface Args { /** --payload 은 여러 번 줄 수 있다. 비면 데모 payload 로 굽는다. */ payloads: string[]; out: string; + /** 굽지 않고 **이미 구워진 HTML 의 자산 주소만** 새 번들로 갈아 끼운다. `main()` 주석 참조. */ + refreshAssets: boolean; + /** payload 가 사는 디렉토리. 자산 주소를 갈아 끼울 대상을 **payload 가 있는 슬러그로** 좁힌다. */ + payloadDir: string; } function parseArgs(): Args { - const args: Args = {payloads: [], out: join(SITE_ROOT, 'out')}; + const args: Args = { + payloads: [], + out: join(SITE_ROOT, 'out'), + refreshAssets: false, + payloadDir: join(SITE_ROOT, 'payloads'), + }; for (const arg of process.argv.slice(2)) { const [key, value] = arg.replace(/^--/, '').split('='); if (key === 'payload' && value) args.payloads.push(value); if (key === 'out' && value) args.out = resolve(process.cwd(), value); + if (key === 'refresh-assets') args.refreshAssets = true; + if (key === 'payload-dir' && value) args.payloadDir = resolve(process.cwd(), value); } return args; } @@ -298,6 +311,156 @@ function copyDirectoryFiles(source: string, destination: string) { } } +/** 내려받은 사진이 놓이는 폴더. 사이트 디렉토리 안이라 Azure 발행 때 함께 올라간다. */ +const MEDIA_DIR = 'img'; + +/** 한 장당 상한(바이트). 원본이 통짜 PNG 인 경우가 있어 막아 둔다 — 넘으면 원래 주소로 둔다. */ +const MEDIA_MAX_BYTES = 8 * 1024 * 1024; + +const MEDIA_EXT: Record = { + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/avif': '.avif', + 'image/gif': '.gif', +}; + +/** + * 사진을 **우리 오리진으로 옮긴다.** + * + * ★ 왜 (2026-09-15, 실측) + * 발행본 사진은 수집한 자리(`*.pstatic.net` · `tong.visitkorea.or.kr`)를 그대로 가리켰다. + * 그 호스트들은 `Access-Control-Allow-Origin` 을 주지 않는다 — 그래서 그 사진을 캔버스에 + * 그리면 **캔버스가 오염돼 파일로 못 뽑는다**(브라우저 정책). 엽서 쓰기의 저장·공유가 + * 모든 발행 사이트에서 막혀 있었고("이 사진은 다른 사이트에 올라와 있어…"), + * 화면에는 미리보기만 남았다. 클라이언트에서는 넘을 방법이 없다 — CORS 없는 `fetch` 도 + * 같은 벽에 막힌다. **같은 오리진에 파일이 있어야** 풀린다. + * ★ 덤이 아니라 같이 딸려 오는 것: 남의 CDN 이 핫링크를 끊거나 주소를 바꾸면 사진이 + * 통째로 사라지는데, 옮겨 놓으면 그 일이 우리 사이트를 건드리지 못한다. + * ★ **재게시 권리(DECISIONS 1-2)의 결론을 앞당기지 않는다.** 화면에 이미 싣고 있는 것만 + * 같은 자리로 옮기는 것이고, `originUrl` · `sourceType` 은 그대로 남는다 — + * "불가" 로 결론 나면 `sourceType = CRAWL` 을 발행에서 빼는 그 대응이 그대로 먹는다. + * ★ 실패는 조용히 넘긴다. 못 받은 사진은 **원래 주소를 그대로 쓴다** — 사진이 사라지는 것보다 + * 공유가 막힌 채로 보이는 쪽이 낫다. + */ +async function mirrorMedia(payload: SitePayload, outRoot: string) { + const items = (payload.media ?? []).filter((item) => /^https?:\/\//i.test(item.url ?? '')); + if (items.length === 0) return; + + const siteDir = join(outRoot, SITE_DIR, payload.site.slug); + const dir = join(siteDir, MEDIA_DIR); + // ★ 절대 주소로 바꾼다. 이 주소는 `` 뿐 아니라 og:image · JSON-LD 의 image 로도 + // 나가는데, 그 둘은 절대 주소여야 한다(상대 주소를 주면 크롤러마다 다르게 읽는다). + // 수집 주소도 절대였으니 바뀌는 것은 호스트뿐이다. + const publicBase = joinUrl(payload.site.origin, payload.site.basePath); + mkdirSync(dir, {recursive: true}); + + const wanted = new Set(); + let fetched = 0; + + for (const item of items) { + const origin = item.url; + // 파일명은 **주소의 해시**다. 같은 사진이 두 사이트에 있어도 각자 폴더라 부딪히지 않고, + // 주소가 그대로면 이름도 그대로라 다시 구워도 내려받지 않는다. + const stem = createHash('sha1').update(origin).digest('hex').slice(0, 16); + const hit = readdirSync(dir).find((name) => name.startsWith(`${stem}.`)); + if (hit) { + wanted.add(hit); + item.url = joinUrl(publicBase, `${MEDIA_DIR}/${hit}`); + continue; + } + + try { + const res = await fetch(origin, { + // 봇 차단을 우회하는 게 아니라, 기본 UA 를 거절하는 CDN 에 평범한 브라우저로 보이게 한다. + headers: {'user-agent': 'Mozilla/5.0 (compatible; o2o-web4ai/1.0)'}, + signal: AbortSignal.timeout(15_000), + }); + const type = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase(); + const ext = MEDIA_EXT[type]; + if (!res.ok || !ext) throw new Error(`${res.status} ${type || '타입 없음'}`); + const body = new Uint8Array(await res.arrayBuffer()); + if (body.byteLength === 0 || body.byteLength > MEDIA_MAX_BYTES) { + throw new Error(`${body.byteLength} 바이트`); + } + const name = `${stem}${ext}`; + await writeFileAsync(join(dir, name), body); + wanted.add(name); + item.url = joinUrl(publicBase, `${MEDIA_DIR}/${name}`); + fetched += 1; + } catch (ex) { + console.warn(` ! 사진을 못 받았습니다(원래 주소를 씁니다): ${origin} — ${ex}`); + } + } + + // 지난 발행의 사진은 치운다 — 노래(copySongs)와 같은 이유다. 안 치우면 사장님이 사진을 + // 바꿀 때마다 쌓이고, Azure 발행 때 그대로 같이 올라간다. + for (const name of readdirSync(dir)) { + if (!wanted.has(name)) rmSync(join(dir, name), {force: true}); + } + if (fetched > 0) console.log(` 사진 ${fetched}장을 내려받았습니다 (총 ${wanted.size}장)`); +} + +/** + * 이미 구워진 사이트의 **자산 주소만** 새 번들로 갈아 끼운다. 내용은 건드리지 않는다. + * + * ★ 왜 이게 기본인가 (2026-09-15 대표 지시: "새로 만드는거만 기능적용, 나머지는 css js + * 에셋 주소만 바꿔치기") + * 예전에는 컨테이너가 뜰 때마다 payload 전부를 다시 구웠다. 그러면 렌더러를 한 줄 고칠 + * 때마다 **이미 나가 있는 사이트의 HTML 이 통째로 바뀐다** — 사장님이 발행한 적 없는데 + * 내용이 달라지고, 구글이 다시 읽어 가는 값도 달라진다. 새 기능은 **다음 발행 때** 들어간다. + * ★ 그런데 번들 파일명은 콘텐츠 해시라, 아무것도 안 하면 옛 HTML 이 옛 번들을 가리킨 채 + * 남는다. 그 자산은 `referencedAssets` 덕에 지워지지는 않지만(AGENTS.md), 디자인 수정이 + * 영영 반영되지 않는다. 그래서 **주소만** 바꾼다 — 시연본(`/s/stay`)에 하던 그 일이다 + * (mockup/README "번들만 갈아 끼운다"). + * ★ 서버가 그린 HTML 은 옛 렌더러의 것이고 붙는 스크립트는 새 렌더러다. 하이드레이션이 + * 어긋나면 리액트가 그 자리에서 다시 그린다 — 손님 화면은 새것, 크롤러가 읽는 정적 + * HTML 은 옛것이다. 그 둘을 맞추는 방법은 재발행뿐이고, 그건 사장님이 누르는 것이다. + * ★ 이번에 구운 사이트는 건너뛴다(이미 새 주소다). + * ★ ★ **payload 가 없는 디렉토리는 건드리지 않는다** — 목업(`stay3` · `*.old`)이다. + * 손으로 만든 유일본이라 다시 만들 수 없고(AGENTS.md 함정 1), 옛 렌더러가 그린 HTML 에 + * 최신 번들을 물리면 어떻게 깨지든 되돌릴 방법이 없다. 그것들의 번들 교체는 사람이 + * 한다(mockup/README "번들만 갈아 끼운다"). 보호 슬러그도 같은 이유로 건너뛴다. + */ +function refreshBakedAssets( + outRoot: string, + assets: {script: string; css: string[]}, + baked: Set, + payloadDir: string, +) { + const sitesDir = join(outRoot, SITE_DIR); + if (!existsSync(sitesDir)) return; + + const owned = new Set( + (existsSync(payloadDir) ? readdirSync(payloadDir) : []) + .filter((name) => name.endsWith('.json') && !name.startsWith('.')) + .map((name) => name.slice(0, -'.json'.length)), + ); + + const css = assets.css[0]; + let changed = 0; + + for (const entry of readdirSync(sitesDir, {withFileTypes: true})) { + if (!entry.isDirectory()) continue; + if (baked.has(entry.name) || PROTECTED_SLUGS.has(entry.name)) continue; + if (!owned.has(entry.name)) continue; + + const file = join(sitesDir, entry.name, 'index.html'); + if (!existsSync(file)) continue; + const before = readFileSync(file, 'utf-8'); + // 접두사(`/assets` · `/sites/assets`)는 그대로 두고 파일명만 바꾼다 — 사이트가 + // 서브패스에 마운트돼 있으면 접두사가 다르다(assetPlan). + let after = before.replace(/(assets\/)index-[A-Za-z0-9_-]+\.js/g, `$1${assets.script}`); + if (css) after = after.replace(/(assets\/)index-[A-Za-z0-9_-]+\.css/g, `$1${css}`); + if (after === before) continue; + writeFileSync(file, after, 'utf-8'); + changed += 1; + } + + console.log(` ✓ 자산 주소 갱신 ${changed}곳 (내용은 그대로 — 새 기능은 다음 발행 때)`); +} + function assetPlan(payload: SitePayload, outRoot: string, siteDir: string) { const basePath = payload.site.basePath.replace(/\/+$/, ''); const suffix = `/${SITE_DIR}/${payload.site.slug}`; @@ -855,11 +1018,26 @@ function writeIndexNowKey(outRoot: string) { console.log(' ✓ IndexNow 키 파일'); } -function main() { +async function main() { const args = parseArgs(); - const loaded = loadPayloads(args.payloads); const assets = readAssets(); + /* + * ★ 기동 모드 — 굽지 않고 자산 주소만 갈아 끼운다(`refreshBakedAssets` 주석). + * 컨테이너가 뜰 때 전부 다시 굽던 자리다. 2026-09-15 대표 지시로 바뀌었다: + * **새로 발행하는 사이트만 새 기능을 받는다.** + */ + if (args.refreshAssets && args.payloads.length === 0) { + console.log(`[prerender] 자산 주소만 갱신 → ${args.out}`); + writeSharedAssets(args.out, referencedAssets(args.out)); + refreshBakedAssets(args.out, assets, new Set(), args.payloadDir); + writePreviewShell(args.out, assets); + console.log('[prerender] 완료'); + return; + } + + const loaded = loadPayloads(args.payloads); + console.log(`[prerender] 사이트 ${loaded.length}개 → ${args.out}`); // ★ 굽기 **전에** 참조를 훑는다. 이번에 다시 굽지 않는 사이트(payload 가 없는 목업 포함)가 @@ -870,6 +1048,8 @@ function main() { writeSharedAssets(args.out, referenced); let failed = 0; + /** 이번에 실제로 구운 슬러그. 자산 주소 갱신에서 빼려고 모은다(이미 새 주소다). */ + const baked = new Set(); /** 루트 기계용 파일을 쓸 오리진. 이 호스트의 사이트는 전부 같은 오리진을 쓴다. */ let origin = ''; @@ -920,8 +1100,12 @@ function main() { } try { + // ★ 굽기 **전에** 사진을 우리 자리로 옮긴다 — 렌더·JSON-LD·og:image 가 전부 같은 + // payload 를 보므로, 여기서 주소를 바꿔 놓아야 한 벌로 맞는다. + await mirrorMedia(entry.payload, args.out); const result = prerenderSite(entry.payload, args.out, assets, referenced); const payload = result.payload; + baked.add(payload.site.slug); origin = origin || payload.site.origin; const home = joinUrl(payload.site.origin, payload.site.basePath); console.log( @@ -963,6 +1147,9 @@ function main() { } } + // ★ 이번에 안 구운 사이트도 **번들 주소는** 새것으로 맞춰 둔다. 내용은 그대로다. + if (args.refreshAssets) refreshBakedAssets(args.out, assets, baked, args.payloadDir); + // ★ 실패한 사이트가 있어도 루트 파일은 갱신한다 — 성공한 사이트까지 색인에서 빠질 이유가 없다. // (인덱스는 디스크를 훑으므로 실패한 사이트는 애초에 들어가지 않는다.) writePreviewShell(args.out, assets); @@ -976,4 +1163,4 @@ function main() { console.log('[prerender] 완료'); } -main(); +void main(); diff --git a/solution/site/scripts/watch-payloads.mjs b/solution/site/scripts/watch-payloads.mjs index ea8f962..55abc51 100644 --- a/solution/site/scripts/watch-payloads.mjs +++ b/solution/site/scripts/watch-payloads.mjs @@ -33,6 +33,7 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(HERE, '..'); const PAYLOAD_DIR = join(ROOT, 'payloads'); const PRERENDER_JS = join(ROOT, 'dist', 'prerender', 'prerender.js'); +const SITES_DIR = join(ROOT, 'out', 's'); const ONCE = process.argv.includes('--once'); /** 폴링 간격. 발행은 분 단위 작업이라 2초 지연은 문제가 되지 않는다. */ @@ -156,6 +157,27 @@ function prerender(files, reason) { return run('node', [PRERENDER_JS, ...files.map((file) => `--payload=${file}`)], label); } +/** + * 기동 때 하는 일 — **굽지 않고 자산 주소만 갈아 끼운다.** + * + * ★ 왜 (2026-09-15 대표 지시: "전체 재굽기 할 필요가 없어, 사장님이 재발행하면 끝인데 / + * css js만 안 깨지게 하란 말이야") + * 예전에는 여기서 payload 를 전부 다시 구웠다. 그러면 렌더러를 고칠 때마다 이미 나가 있는 + * 사이트의 HTML 이 통째로 바뀐다 — 사장님은 발행한 적이 없는데 내용이 달라진다. + * 새 기능은 **다음 발행 때** 들어가면 되고, 기존 사이트는 번들만 안 깨지면 된다. + * ★ 번들 파일명이 콘텐츠 해시라 이것마저 안 하면 옛 HTML 이 옛 번들을 가리킨 채 굳는다. + * 자산 자체는 지워지지 않지만(`prerender.ts` referencedAssets) 디자인 수정이 영영 안 간다. + */ +function refreshAssets() { + // payload 디렉토리를 넘긴다 — 자산 주소를 갈아 끼울 대상을 **payload 가 있는 슬러그로** + // 좁히려는 것이다. 목업(payload 없는 디렉토리)은 손으로 바꾼다(AGENTS.md 함정 1). + return run( + 'node', + [PRERENDER_JS, '--refresh-assets', `--payload-dir=${PAYLOAD_DIR}`], + '자산 주소 갱신 — 기동', + ); +} + // ── 큐 ──────────────────────────────────────────────────────────────────── /** 굽기를 기다리는 payload 경로. 굽는 동안 들어온 변경은 여기에 쌓였다가 이어서 돈다. */ const pending = new Set(); @@ -209,6 +231,11 @@ function basenameOf(file) { return file.slice(file.lastIndexOf('/') + 1); } +/** 그 payload 가 이미 구워져 있나. 파일명이 슬러그다(백엔드가 `.json` 으로 쓴다). */ +function bakedIndexOf(file) { + return join(SITES_DIR, basenameOf(file).replace(/\.json$/, ''), 'index.html'); +} + /** payload 디렉토리의 *.json 목록. 백엔드가 rename 전에 쓰는 임시파일(.tmp)은 건너뛴다. */ function listPayloads() { if (!existsSync(PAYLOAD_DIR)) return []; @@ -228,11 +255,16 @@ async function main() { return; } - // 기동 시 한 번은 전부 굽는다. 감시가 꺼져 있는 동안 발행된 것도 사이트가 있어야 하고, - // 코드가 바뀌었다면 번들이 새로 나왔으므로 기존 사이트도 다시 구워야 맞다. + // ★ 기동 시 전부 굽지 않는다(refreshAssets 주석). 자산 주소만 맞추고, 내용은 + // 사장님이 다시 발행할 때 새 렌더러로 구워진다. const all = listPayloads(); const seen = new Map(all.map((file) => [file, statSync(file).mtimeMs])); - await prerender(all, '기동'); + await refreshAssets(); + + // 아직 한 번도 안 구워진 payload 는 굽는다 — 감시가 꺼져 있는 동안 발행됐거나 볼륨이 + // 비어 있던 경우다. 사이트가 아예 없는 것과 "옛 내용으로 서 있는 것" 은 다른 문제다. + const unbuilt = all.filter((file) => !existsSync(bakedIndexOf(file))); + await prerender(unbuilt, '아직 안 구워진 것'); if (ONCE) return; diff --git a/solution/site/src/layouts/editorial/SectionHead.tsx b/solution/site/src/layouts/editorial/SectionHead.tsx index 641f78e..d98f6b6 100644 --- a/solution/site/src/layouts/editorial/SectionHead.tsx +++ b/solution/site/src/layouts/editorial/SectionHead.tsx @@ -60,6 +60,7 @@ const ANCHOR: Record = { songs: 'songs', daily: 'daily', chronicle: 'chronicle', + reading: 'reading', people: 'people', quiz: 'quiz', postcard: 'postcard', @@ -84,6 +85,7 @@ const FALLBACK_LABEL: Record = { songs: '노래', daily: '일력', chronicle: '연표', + reading: '읽기', people: '인물', quiz: '퀴즈', postcard: '엽서', diff --git a/solution/site/src/lib/postcard-canvas.ts b/solution/site/src/lib/postcard-canvas.ts index e6c2e96..a2a3ccc 100644 --- a/solution/site/src/lib/postcard-canvas.ts +++ b/solution/site/src/lib/postcard-canvas.ts @@ -110,7 +110,10 @@ function stampLines(ctx: CanvasRenderingContext2D, name: string, maxWidth: numbe * 실측(2026-09-14): 발행본 사진은 네이버 CDN(`*.pstatic.net`)에 있고 그쪽은 * `Access-Control-Allow-Origin` 을 주지 않는다 — 그래서 이 시도는 실패하고, * **CORS 없이 다시 받아 미리보기만** 세운다(저장·공유는 부르는 쪽이 막는다). - * ★ 근본 해결은 사진을 우리 오리진으로 옮기는 것이다(시연본이 `img/mirror/` 로 그렇게 한다). + * ★ **2026-09-15 에 근본을 고쳤다** — 굽는 쪽이 사진을 내려받아 `s//img/` 에 두고 + * payload 의 주소를 우리 오리진으로 바꾼다(`scripts/prerender.ts` `mirrorMedia`). + * 같은 오리진이면 오염되지 않아 저장·공유가 열린다. 아래 폴백은 **못 받아 온 사진** + * (CDN 이 거절했거나 타입이 이상한 것)만을 위한 것이라 이제 드물게 탄다. */ async function loadImage(url: string): Promise<{img: HTMLImageElement; exportable: boolean}> { const tryLoad = (crossOrigin: string | null) => new Promise((resolve, reject) => { diff --git a/solution/site/src/sections/items/PostcardMakerSection.tsx b/solution/site/src/sections/items/PostcardMakerSection.tsx index 774b407..9ef6843 100644 --- a/solution/site/src/sections/items/PostcardMakerSection.tsx +++ b/solution/site/src/sections/items/PostcardMakerSection.tsx @@ -25,7 +25,9 @@ export function PostcardMakerSection() { const [hint, setHint] = useState(''); const [touch, setTouch] = useState(false); /* 사진이 남의 도메인이면 캔버스가 오염돼 파일로 못 뽑는다(postcard-canvas.loadImage 주석). - 그때는 미리보기만 남기고 저장·공유를 막는다 — 눌러도 안 되는 단추를 두지 않는다. */ + 굽는 쪽이 사진을 우리 오리진으로 옮기고부터(prerender `mirrorMedia`) 여기 걸리는 건 + **내려받기에 실패한 사진**뿐이다. 그때는 미리보기만 남기고 저장·공유를 막는다 — + 눌러도 안 되는 단추를 두지 않는다. */ const [exportable, setExportable] = useState(true); useEffect(() => { @@ -109,15 +111,19 @@ export function PostcardMakerSection() { title="엽서 쓰기" lead={`${placeName}에서 보내는 한 장을 직접 써 봅니다. 사진을 고르고 한 마디를 적으면 그 자리에서 엽서가 되고, 그대로 공유하거나 저장할 수 있습니다.`} > -

+ {/* ★ 폭을 묶어 둔다 (2026-09-15 대표: "엽서 ui 너무 큼"). 엽서는 1:1 이라 열 폭을 + 그대로 주면 넓은 화면에서 한 변이 700px 넘는 정사각이 서서, 예약하러 온 손님이 + 이 한 칸을 스크롤로 넘겨야 다음 섹션에 닿는다. 미리보기는 손바닥만 하면 된다. + ★ 320px 도 크다고 했다(2026-09-15, 같은 날 두 번째) — 240px 로 내렸다. */} +
-
+
{photos.map((photo, i) => ( @@ -125,9 +131,10 @@ export function PostcardMakerSection() { key={photo.url} type="button" onClick={() => setPicked(i)} - aria-pressed={i === picked} + role="radio" + aria-checked={i === picked} aria-label={`사진 ${i + 1}`} - className="border-line size-16 shrink-0 overflow-hidden rounded border transition-opacity" + className="border-line size-10 shrink-0 overflow-hidden rounded border transition-opacity" style={{opacity: i === picked ? 1 : 0.5}} > @@ -137,30 +144,31 @@ export function PostcardMakerSection() {